Skip to content
This repository has been archived by the owner on Jul 2, 2024. It is now read-only.

EVG-20173: Allow users to log out via user dropdown #2025

Merged
merged 5 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions cypress/integration/auth.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 9 additions & 4 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const LOGIN_COOKIE = "mci-token";
const loginURL = "http://localhost:9090/login";
const user = {
username: "admin",
password: "password",
Expand Down Expand Up @@ -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})`)
Expand Down
5 changes: 5 additions & 0 deletions cypress/support/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/components/Header/UserDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +11,7 @@ export const UserDropdown = () => {
const { user } = data || {};
const { displayName } = user || {};

const { logoutAndRedirect } = useAuthDispatchContext();
const { sendEvent } = useNavbarAnalytics();

const menuItems = [
Expand All @@ -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 (
Expand Down
140 changes: 140 additions & 0 deletions src/context/auth.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<AuthProvider>{children}</AuthProvider>
);

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");
});
});
});
});
43 changes: 22 additions & 21 deletions src/context/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createContext, useContext, useMemo, useReducer } from "react";
import axios from "axios";
import { environmentVariables } from "utils";
import { leaveBreadcrumb } from "utils/errorReporting";

Expand Down Expand Up @@ -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) {
Expand All @@ -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"
);
Expand All @@ -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;
Expand Down