From 1b5a6632434e38be21592ce4de1234c6121e37b4 Mon Sep 17 00:00:00 2001 From: minnakt Date: Thu, 7 Sep 2023 16:23:02 -0400 Subject: [PATCH 1/3] EVG-20173: Add log out button --- cypress/integration/auth.ts | 13 ++- cypress/support/commands.ts | 20 +++- cypress/support/e2e.ts | 5 + src/components/Header/UserDropdown.tsx | 7 ++ src/context/auth.test.tsx | 140 +++++++++++++++++++++++++ src/context/auth.tsx | 43 ++++---- 6 files changed, 199 insertions(+), 29 deletions(-) create mode 100644 src/context/auth.test.tsx diff --git a/cypress/integration/auth.ts b/cypress/integration/auth.ts index ace2072aef..a70a8638e2 100644 --- a/cypress/integration/auth.ts +++ b/cypress/integration/auth.ts @@ -1,17 +1,24 @@ 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"); }); + it("Can log out via the dropdown", () => { + cy.visit("/"); + 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"); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 59d94f56ab..be5b941d41 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,10 +59,22 @@ Cypress.Commands.add("getInputByLabel", (label: string) => { /* login */ Cypress.Commands.add("login", () => { - cy.getCookie(LOGIN_COOKIE).then((c) => { - if (!c) { - cy.request("POST", loginURL, { ...user }); + const args = { ...user }; + cy.session( + // Username & password can be used as the cache key too + args, + () => { + cy.origin("http://localhost:9090", { args }, ({ password, username }) => { + cy.request("POST", "/login", { username, password }); + }); } + ); +}); + +/* logout */ +Cypress.Commands.add("logout", () => { + cy.origin("http://localhost:9090", () => { + cy.request({ url: "/logout", followRedirect: false }); }); }); 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..de9ebad158 --- /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: "http://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; From aebc2bbfad624a5470e1a75c6e970b4e13618dce Mon Sep 17 00:00:00 2001 From: minnakt Date: Fri, 8 Sep 2023 09:00:56 -0400 Subject: [PATCH 2/3] Don't use session for now --- cypress/support/commands.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index be5b941d41..83fb2affbd 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -59,16 +59,11 @@ Cypress.Commands.add("getInputByLabel", (label: string) => { /* login */ Cypress.Commands.add("login", () => { - const args = { ...user }; - cy.session( - // Username & password can be used as the cache key too - args, - () => { - cy.origin("http://localhost:9090", { args }, ({ password, username }) => { - cy.request("POST", "/login", { username, password }); - }); + cy.getCookie("mci-token").then((c) => { + if (!c) { + cy.request("POST", "http://localhost:9090/login", { ...user }); } - ); + }); }); /* logout */ From 5759a88e94af545e647cf89cadd406d635135dcf Mon Sep 17 00:00:00 2001 From: minnakt Date: Fri, 8 Sep 2023 11:30:03 -0400 Subject: [PATCH 3/3] Slightly better Cypress assertions --- cypress/integration/auth.ts | 7 ++++--- src/context/auth.test.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cypress/integration/auth.ts b/cypress/integration/auth.ts index a70a8638e2..02ac94a4f1 100644 --- a/cypress/integration/auth.ts +++ b/cypress/integration/auth.ts @@ -9,11 +9,12 @@ describe("Auth", () => { 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"); @@ -21,11 +22,11 @@ describe("Auth", () => { 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/src/context/auth.test.tsx b/src/context/auth.test.tsx index de9ebad158..e534e53a38 100644 --- a/src/context/auth.test.tsx +++ b/src/context/auth.test.tsx @@ -97,7 +97,7 @@ describe("auth", () => { beforeEach(() => { Object.defineProperty(window, "location", { value: { - href: "http://just-a-placeholder.com", + href: "https://just-a-placeholder.com", }, writable: true, });