diff --git a/frontend/dashboard/.env.test b/frontend/dashboard/.env.test new file mode 100644 index 000000000..b79e983d6 --- /dev/null +++ b/frontend/dashboard/.env.test @@ -0,0 +1 @@ +VITE_ENABLE_MOCKING = true diff --git a/frontend/dashboard/src/RelayEnvironment.ts b/frontend/dashboard/src/RelayEnvironment.ts index 7b87c40de..cca3ea096 100644 --- a/frontend/dashboard/src/RelayEnvironment.ts +++ b/frontend/dashboard/src/RelayEnvironment.ts @@ -10,6 +10,9 @@ import { } from "relay-runtime"; import { getKeycloak } from "./keycloak"; import { createClient } from "graphql-ws"; +import { AuthState } from "@diamondlightsource/sci-react-ui"; +import { parseJwt } from "./routes/utils"; +import { JSONObject } from "workflows-lib"; const HTTP_ENDPOINT = import.meta.env.VITE_GRAPH_URL; const WS_ENDPOINT = import.meta.env.VITE_GRAPH_WS_URL; @@ -55,7 +58,6 @@ const fetchFn: FetchFunction = async (request, variables) => { if (!keycloak.authenticated) { await ensureKeycloakInit(); } - if (keycloak.token) { const resp = await fetch(HTTP_ENDPOINT, { method: "POST", @@ -130,3 +132,22 @@ export async function getRelayEnvironment(): Promise { } return RelayEnvironment; } + +export async function getUser(): Promise { + if (!keycloak.authenticated) { + await ensureKeycloakInit(); + } + if (keycloak.token) { + let parsedToken: JSONObject = {}; + try { + parsedToken = parseJwt(keycloak.token); + } catch (error) { + console.error("Could not parse JWT: ", error); + } + const user: AuthState = { + name: parsedToken.name as string, + fedid: (parsedToken.preferred_username ?? parsedToken.fedid) as string, + }; + return user; + } else return null; +} diff --git a/frontend/dashboard/src/mocks/mockKeycloak.ts b/frontend/dashboard/src/mocks/mockKeycloak.ts index a0e7ec026..cddea891c 100644 --- a/frontend/dashboard/src/mocks/mockKeycloak.ts +++ b/frontend/dashboard/src/mocks/mockKeycloak.ts @@ -4,14 +4,17 @@ const createMockJwt = (payload: object) => { return `${header}.${body}.signature`; }; -const mockToken = createMockJwt({ +export const mockJwtPayload = { sub: "mock-user", preferred_username: "mockuser", email: "mock@diamond.ac.uk", exp: Number.MAX_SAFE_INTEGER, iat: Number.MIN_SAFE_INTEGER, iss: "https://authn.diamond.ac.uk/realms/master", -}); + name: "Mo C. Kuser", +}; + +const mockToken = createMockJwt(mockJwtPayload); const mockKeycloak = { init: () => Promise.resolve(true), diff --git a/frontend/dashboard/src/routes/utils.ts b/frontend/dashboard/src/routes/utils.ts index 9fb2c2333..b8c9ffa8e 100644 --- a/frontend/dashboard/src/routes/utils.ts +++ b/frontend/dashboard/src/routes/utils.ts @@ -5,7 +5,7 @@ import { visitTextToVisit, convertStringToScienceGroup, } from "workflows-lib/lib/utils/commonUtils"; -import { WorkflowTemplatesFilter } from "workflows-lib"; +import { JSONObject, WorkflowTemplatesFilter } from "workflows-lib"; export const useVisitInput = (initialVisitId?: string | null) => { const navigate = useNavigate(); @@ -56,3 +56,19 @@ export function getFilterFromParams(searchParams: URLSearchParams) { .filter((entry) => entry !== undefined); return filter; } + +export function parseJwt(token: string) { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split("") + .map(function (c) { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(""), + ); + + return JSON.parse(jsonPayload) as JSONObject; +} diff --git a/frontend/dashboard/tests/RelayEnvironment.test.ts b/frontend/dashboard/tests/RelayEnvironment.test.ts new file mode 100644 index 000000000..1be4588d9 --- /dev/null +++ b/frontend/dashboard/tests/RelayEnvironment.test.ts @@ -0,0 +1,30 @@ +import "@testing-library/jest-dom"; +import { getUser } from "../src/RelayEnvironment"; +import * as utils from "../src/routes/utils"; + +describe("getUser", () => { + it("should return the mock user", async () => { + expect(await getUser()).toStrictEqual({ + name: "Mo C. Kuser", + fedid: "mockuser", + }); + }); + + it("should handle failed JWT parsing", async () => { + vi.spyOn(utils, "parseJwt").mockImplementationOnce(() => { + throw new Error("test error"); + }); + expect(await getUser()).toStrictEqual({ + name: undefined, + fedid: undefined, + }); + }); + + it("should handle missing fedid/user", async () => { + vi.spyOn(utils, "parseJwt").mockReturnValueOnce({ name: "I. Matest" }); + expect(await getUser()).toStrictEqual({ + name: "I. Matest", + fedid: undefined, + }); + }); +}); diff --git a/frontend/dashboard/tests/utils.test.ts b/frontend/dashboard/tests/utils.test.ts index 3d974eb27..c6cfc59ed 100644 --- a/frontend/dashboard/tests/utils.test.ts +++ b/frontend/dashboard/tests/utils.test.ts @@ -1,5 +1,9 @@ import "@testing-library/jest-dom"; -import { getFilterFromParams } from "../src/routes/utils"; +import { getFilterFromParams, parseJwt } from "../src/routes/utils"; +import { + mockJwtPayload, + default as mockKeycloak, +} from "../src/mocks/mockKeycloak"; describe("getFilterFromParams", () => { test.each([ @@ -14,3 +18,9 @@ describe("getFilterFromParams", () => { }); }); }); + +describe("parseJwt", () => { + it("should decode a JWT", () => { + expect(parseJwt(mockKeycloak.token)).toStrictEqual(mockJwtPayload); + }); +}); diff --git a/frontend/workflows-lib/lib/components/workflow/WorkflowsNavbar.tsx b/frontend/workflows-lib/lib/components/workflow/WorkflowsNavbar.tsx index dcb7daf50..5fa3a5cc9 100644 --- a/frontend/workflows-lib/lib/components/workflow/WorkflowsNavbar.tsx +++ b/frontend/workflows-lib/lib/components/workflow/WorkflowsNavbar.tsx @@ -5,62 +5,86 @@ import { DiamondTheme, NavLinks, NavLink, + User, + AuthState, } from "@diamondlightsource/sci-react-ui"; +import { getUser } from "dashboard/src/RelayEnvironment"; +import { useEffect, useState } from "react"; +import { externalRedirect } from "workflows-lib/lib/utils/commonUtils"; interface WorkflowsNavbarProps { sessionInfo?: string; } -const WorkflowsNavbar: React.FC = ({ sessionInfo }) => ( - - - - Home - - - Workflows - - - Templates - - - - } - rightSlot={ - <> - {sessionInfo && ( - - {sessionInfo} - - )} - - } - /> -); +const handleLogout = () => { + externalRedirect( + "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout", + ); +}; + +const WorkflowsNavbar: React.FC = ({ sessionInfo }) => { + const [user, setUser] = useState(null); + + useEffect(() => { + getUser() + .then(setUser) + .catch(() => { + console.error("Failed to fetch user from JWT"); + }); + }, []); + + return ( + + + + Home + + + Workflows + + + Templates + + + + } + rightSlot={ + <> + {sessionInfo && ( + + {sessionInfo} + + )} + + + } + /> + ); +}; export default WorkflowsNavbar; diff --git a/frontend/workflows-lib/lib/utils/commonUtils.ts b/frontend/workflows-lib/lib/utils/commonUtils.ts index 3e434f841..cf6f32733 100644 --- a/frontend/workflows-lib/lib/utils/commonUtils.ts +++ b/frontend/workflows-lib/lib/utils/commonUtils.ts @@ -74,3 +74,8 @@ export function convertStringToScienceGroup( ? (input as ScienceGroup) : undefined; } + +//** An abstraction to allow mocking of window.location.assign in tests */ +export function externalRedirect(url: string) { + window.location.assign(url); +} diff --git a/frontend/workflows-lib/tests/components/WorkflowsNavbar.test.tsx b/frontend/workflows-lib/tests/components/WorkflowsNavbar.test.tsx index 25f52b080..cdc12d196 100644 --- a/frontend/workflows-lib/tests/components/WorkflowsNavbar.test.tsx +++ b/frontend/workflows-lib/tests/components/WorkflowsNavbar.test.tsx @@ -1,11 +1,24 @@ -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import { ThemeProvider } from "@mui/material/styles"; -import { DiamondTheme } from "@diamondlightsource/sci-react-ui"; -import WorkflowsNavbar from "../../lib/components/workflow/WorkflowsNavbar"; +import { DiamondTheme, AuthState } from "@diamondlightsource/sci-react-ui"; import { BrowserRouter } from "react-router-dom"; +import { getUser } from "dashboard/src/RelayEnvironment"; +import userEvent from "@testing-library/user-event"; +import WorkflowsNavbar from "../../lib/components/workflow/WorkflowsNavbar"; +import * as commonUtils from "../../lib/utils/commonUtils"; + +vi.mock("dashboard/src/RelayEnvironment", () => ({ + getUser: vi.fn(() => Promise.resolve(null)), +})); describe("WorkflowsNavbar", () => { + const user = userEvent.setup(); + const testUser: AuthState = { + name: "Tess Tuser", + fedid: "ab12345", + }; + it("renders with title and sessionInfo", () => { const { getByText } = render( @@ -30,4 +43,35 @@ describe("WorkflowsNavbar", () => { `color: ${DiamondTheme.palette.primary.contrastText}`, ); }); + + it("displays the logged in user", async () => { + vi.mocked(getUser).mockReturnValue(Promise.resolve(testUser)); + render( + + + + + , + ); + expect(await screen.findByText("Tess Tuser")).toBeVisible(); + expect(screen.getByText("ab12345")).toBeVisible(); + }); + + it("redirects to logout", async () => { + vi.mocked(getUser).mockReturnValue(Promise.resolve(testUser)); + const redirectSpy = vi.spyOn(commonUtils, "externalRedirect"); + const url = + "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout"; + render( + + + + + , + ); + await screen.findByText("Tess Tuser"); + await user.click(screen.getByRole("button", { name: "User Avatar" })); + await user.click(await screen.findByText("Logout")); + expect(redirectSpy).toHaveBeenCalledWith(url); + }); });