diff --git a/cypress/integration/auth.ts b/cypress/integration/auth.ts index ace2072aef..02ac94a4f1 100644 --- a/cypress/integration/auth.ts +++ b/cypress/integration/auth.ts @@ -1,24 +1,32 @@ describe("Auth", () => { it("Unauthenticated user is redirected to login page after visiting a private route", () => { - cy.clearCookie("mci-token"); + cy.logout(); cy.visit("/version/123123"); - cy.url().should("include", "/login"); + cy.location("pathname").should("equal", "/login"); }); it("Redirects user to My Patches page after logging in", () => { - cy.clearCookie("mci-token"); + cy.logout(); cy.visit("/"); cy.enterLoginCredentials(); - cy.url().should("include", "/user/admin/patches"); + cy.location("pathname").should("equal", "/user/admin/patches"); + }); + + it("Can log out via the dropdown", () => { + cy.visit("/"); + cy.location("pathname").should("equal", "/user/admin/patches"); + cy.dataCy("user-dropdown-link").click(); + cy.dataCy("log-out").click(); + cy.location("pathname").should("equal", "/login"); }); it("Automatically authenticates user if they are logged in", () => { cy.visit("/version/123123"); - cy.url().should("include", "/version/123123"); + cy.location("pathname").should("equal", "/version/123123"); }); it("Redirects user to their patches page if they are already logged in and visit login page", () => { cy.visit("/login"); - cy.url().should("include", "/user/admin/patches"); + cy.location("pathname").should("equal", "/user/admin/patches"); }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 59d94f56ab..83fb2affbd 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,5 +1,3 @@ -const LOGIN_COOKIE = "mci-token"; -const loginURL = "http://localhost:9090/login"; const user = { username: "admin", password: "password", @@ -61,13 +59,20 @@ Cypress.Commands.add("getInputByLabel", (label: string) => { /* login */ Cypress.Commands.add("login", () => { - cy.getCookie(LOGIN_COOKIE).then((c) => { + cy.getCookie("mci-token").then((c) => { if (!c) { - cy.request("POST", loginURL, { ...user }); + cy.request("POST", "http://localhost:9090/login", { ...user }); } }); }); +/* logout */ +Cypress.Commands.add("logout", () => { + cy.origin("http://localhost:9090", () => { + cy.request({ url: "/logout", followRedirect: false }); + }); +}); + /* toggleTableFilter */ Cypress.Commands.add("toggleTableFilter", (colNum: number) => { cy.get(`.ant-table-thead > tr > :nth-child(${colNum})`) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 840065a2a5..7d3f6af7e6 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -89,6 +89,11 @@ declare global { * @example cy.login() */ login(): void; + /** + * Custom command to log out of the application. + * @example cy.logout() + */ + logout(): void; /** * Custom command to open an antd table filter associated with the * the supplied column number diff --git a/src/components/Header/UserDropdown.tsx b/src/components/Header/UserDropdown.tsx index 4e008fc5db..eec1b6cd99 100644 --- a/src/components/Header/UserDropdown.tsx +++ b/src/components/Header/UserDropdown.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@apollo/client"; import { useNavbarAnalytics } from "analytics"; import { PreferencesTabRoutes, getPreferencesRoute } from "constants/routes"; +import { useAuthDispatchContext } from "context/auth"; import { UserQuery } from "gql/generated/types"; import { GET_USER } from "gql/queries"; import { NavDropdown } from "./NavDropdown"; @@ -10,6 +11,7 @@ export const UserDropdown = () => { const { user } = data || {}; const { displayName } = user || {}; + const { logoutAndRedirect } = useAuthDispatchContext(); const { sendEvent } = useNavbarAnalytics(); const menuItems = [ @@ -23,6 +25,11 @@ export const UserDropdown = () => { to: getPreferencesRoute(PreferencesTabRoutes.Notifications), onClick: () => sendEvent({ name: "Click Notifications Link" }), }, + { + "data-cy": "log-out", + text: "Log out", + onClick: () => logoutAndRedirect(), + }, ]; return ( diff --git a/src/context/auth.test.tsx b/src/context/auth.test.tsx new file mode 100644 index 0000000000..e534e53a38 --- /dev/null +++ b/src/context/auth.test.tsx @@ -0,0 +1,140 @@ +import { act, renderHook, waitFor } from "test_utils"; +import { mockEnvironmentVariables } from "test_utils/utils"; +import { + AuthProvider, + useAuthDispatchContext, + useAuthStateContext, +} from "./auth"; + +const { cleanup, mockEnv } = mockEnvironmentVariables(); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const useAuthHook = () => { + const { devLogin, dispatchAuthenticated, logoutAndRedirect } = + useAuthDispatchContext(); + const { isAuthenticated } = useAuthStateContext(); + return { + devLogin, + dispatchAuthenticated, + logoutAndRedirect, + isAuthenticated, + }; +}; + +describe("auth", () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it("useAuthDispatchContext should error when rendered outside of AuthProvider", () => { + jest.spyOn(console, "error").mockImplementation(); + expect(() => renderHook(() => useAuthDispatchContext())).toThrow( + "useAuthDispatchContext must be used within an auth context provider" + ); + }); + + it("useAuthStateContext should error when rendered outside of AuthProvider", () => { + jest.spyOn(console, "error").mockImplementation(); + expect(() => renderHook(() => useAuthStateContext())).toThrow( + "useAuthStateContext must be used within an auth context provider" + ); + }); + + describe("devLogin", () => { + it("should authenticate when the response is successful", async () => { + const mockFetchPromise = jest.fn().mockResolvedValue({ ok: true }); + jest.spyOn(global, "fetch").mockImplementation(mockFetchPromise); + + const { result } = renderHook(() => useAuthHook(), { + wrapper, + }); + + expect(result.current.isAuthenticated).toBe(false); + result.current.devLogin({ + password: "password", + username: "username", + }); + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + }); + + it("should not authenticate when the response is unsuccessful", () => { + const mockFetchPromise = jest.fn().mockResolvedValue({ ok: false }); + jest.spyOn(global, "fetch").mockImplementation(mockFetchPromise); + + const { result } = renderHook(() => useAuthHook(), { + wrapper, + }); + + expect(result.current.isAuthenticated).toBe(false); + result.current.devLogin({ + password: "password", + username: "username", + }); + expect(result.current.isAuthenticated).toBe(false); + }); + }); + + describe("dispatchAuthenticated", () => { + it("authenticates the user", () => { + const { result } = renderHook(() => useAuthHook(), { + wrapper, + }); + expect(result.current.isAuthenticated).toBe(false); + act(() => { + result.current.dispatchAuthenticated(); + }); + expect(result.current.isAuthenticated).toBe(true); + }); + }); + + describe("logoutAndRedirect", () => { + beforeEach(() => { + Object.defineProperty(window, "location", { + value: { + href: "https://just-a-placeholder.com", + }, + writable: true, + }); + }); + + it("should redirect to the Spruce /login page locally", async () => { + mockEnv("NODE_ENV", "development"); + mockEnv("REACT_APP_SPRUCE_URL", "spruce-url"); + const mockFetchPromise = jest.fn().mockResolvedValue({}); + jest.spyOn(global, "fetch").mockImplementation(mockFetchPromise); + + const { result } = renderHook(() => useAuthHook(), { + wrapper, + }); + + result.current.logoutAndRedirect(); + expect(result.current.isAuthenticated).toBe(false); + await waitFor(() => { + expect(window.location.href).toBe("spruce-url/login"); + }); + }); + + it("should redirect to the Evergreen /login page otherwise", async () => { + mockEnv("NODE_ENV", "production"); + mockEnv("REACT_APP_UI_URL", "evergreen-url"); + const mockFetchPromise = jest.fn().mockResolvedValue({}); + jest.spyOn(global, "fetch").mockImplementation(mockFetchPromise); + + const { result } = renderHook(() => useAuthHook(), { + wrapper, + }); + + result.current.logoutAndRedirect(); + expect(result.current.isAuthenticated).toBe(false); + await waitFor(() => { + expect(window.location.href).toBe("evergreen-url/login"); + }); + }); + }); +}); diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 5a9b5743b3..13f4fb4116 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -1,5 +1,4 @@ import { createContext, useContext, useMemo, useReducer } from "react"; -import axios from "axios"; import { environmentVariables } from "utils"; import { leaveBreadcrumb } from "utils/errorReporting"; @@ -44,25 +43,27 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ () => ({ // This function is only used in local development. devLogin: async ({ password, username }) => { - await axios - .post( - `${getUiUrl()}/login`, - { username, password }, - { withCredentials: true } - ) - .then((response) => { - if (response.status === 200) { - dispatch({ type: "authenticated" }); - } - }); + await fetch(`${getUiUrl()}/login`, { + body: JSON.stringify({ password, username }), + credentials: "include", + method: "POST", + }).then((response) => { + if (response.ok) { + dispatch({ type: "authenticated" }); + } else { + dispatch({ type: "deauthenticated" }); + } + }); }, logoutAndRedirect: async () => { - // attempt log out and redirect to login page - try { - await axios.get(`${getUiUrl()}/logout`); - } catch {} - dispatch({ type: "deauthenticated" }); - window.location.href = `${getLoginDomain()}/login`; + await fetch(`${getUiUrl()}/logout`, { + credentials: "include", + method: "GET", + redirect: "manual", + }).then(() => { + dispatch({ type: "deauthenticated" }); + window.location.href = `${getLoginDomain()}/login`; + }); }, dispatchAuthenticated: () => { if (!state.isAuthenticated) { @@ -85,7 +86,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ const useAuthStateContext = (): AuthState => { const authState = useContext(AuthStateContext); - if (authState === undefined) { + if (authState === null || authState === undefined) { throw new Error( "useAuthStateContext must be used within an auth context provider" ); @@ -95,9 +96,9 @@ const useAuthStateContext = (): AuthState => { const useAuthDispatchContext = (): DispatchContext => { const authDispatch = useContext(AuthDispatchContext); - if (authDispatch === undefined) { + if (authDispatch === null || authDispatch === undefined) { throw new Error( - "useAuthStateContext must be used within an auth context provider" + "useAuthDispatchContext must be used within an auth context provider" ); } return authDispatch;