Skip to content
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
1 change: 1 addition & 0 deletions frontend/dashboard/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_ENABLE_MOCKING = true
23 changes: 22 additions & 1 deletion frontend/dashboard/src/RelayEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -130,3 +132,22 @@ export async function getRelayEnvironment(): Promise<Environment> {
}
return RelayEnvironment;
}

export async function getUser(): Promise<AuthState | null> {
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,
Comment thread
davehadley marked this conversation as resolved.
};
return user;
} else return null;
}
7 changes: 5 additions & 2 deletions frontend/dashboard/src/mocks/mockKeycloak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
JamesDoingStuff marked this conversation as resolved.
};

const mockToken = createMockJwt(mockJwtPayload);

const mockKeycloak = {
init: () => Promise.resolve(true),
Expand Down
18 changes: 17 additions & 1 deletion frontend/dashboard/src/routes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
30 changes: 30 additions & 0 deletions frontend/dashboard/tests/RelayEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
12 changes: 11 additions & 1 deletion frontend/dashboard/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -14,3 +18,9 @@ describe("getFilterFromParams", () => {
});
});
});

describe("parseJwt", () => {
it("should decode a JWT", () => {
expect(parseJwt(mockKeycloak.token)).toStrictEqual(mockJwtPayload);
});
});
126 changes: 75 additions & 51 deletions frontend/workflows-lib/lib/components/workflow/WorkflowsNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkflowsNavbarProps> = ({ sessionInfo }) => (
<Navbar
logo="theme"
leftSlot={
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
flexWrap: "nowrap",
overflow: "hidden",
}}
>
<NavLinks>
<NavLink to="/" linkComponent={Link}>
Home
</NavLink>
<NavLink to="/workflows" linkComponent={Link}>
Workflows
</NavLink>
<NavLink to="/templates" linkComponent={Link}>
Templates
</NavLink>
</NavLinks>
</Box>
}
rightSlot={
<>
{sessionInfo && (
<Typography
sx={{
color: DiamondTheme.palette.primary.contrastText,
fontSize: {
xs: "0.75rem",
sm: "0.8rem",
md: "0.8rem",
lg: "1rem",
},
textAlign: "right",
ml: 2,
whiteSpace: "nowrap",
}}
>
{sessionInfo}
</Typography>
)}
</>
}
/>
);
const handleLogout = () => {
externalRedirect(
"https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout",
);
};

const WorkflowsNavbar: React.FC<WorkflowsNavbarProps> = ({ sessionInfo }) => {
const [user, setUser] = useState<AuthState | null>(null);

useEffect(() => {
getUser()
.then(setUser)
.catch(() => {
console.error("Failed to fetch user from JWT");
});
}, []);

return (
<Navbar
logo="theme"
leftSlot={
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
flexWrap: "nowrap",
overflow: "hidden",
}}
>
<NavLinks>
<NavLink to="/" linkComponent={Link}>
Home
</NavLink>
<NavLink to="/workflows" linkComponent={Link}>
Workflows
</NavLink>
<NavLink to="/templates" linkComponent={Link}>
Templates
</NavLink>
</NavLinks>
</Box>
}
rightSlot={
<>
{sessionInfo && (
<Typography
sx={{
color: DiamondTheme.palette.primary.contrastText,
fontSize: {
xs: "0.75rem",
sm: "0.8rem",
md: "0.8rem",
lg: "1rem",
},
textAlign: "right",
ml: 2,
whiteSpace: "nowrap",
}}
>
{sessionInfo}
</Typography>
)}
<User colour="white" user={user} onLogout={handleLogout}></User>
</>
}
/>
);
};

export default WorkflowsNavbar;
5 changes: 5 additions & 0 deletions frontend/workflows-lib/lib/utils/commonUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
50 changes: 47 additions & 3 deletions frontend/workflows-lib/tests/components/WorkflowsNavbar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider theme={DiamondTheme}>
Expand All @@ -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(
<ThemeProvider theme={DiamondTheme}>
<BrowserRouter>
<WorkflowsNavbar />
</BrowserRouter>
</ThemeProvider>,
);
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(
<ThemeProvider theme={DiamondTheme}>
<BrowserRouter>
<WorkflowsNavbar />
</BrowserRouter>
</ThemeProvider>,
);
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);
});
});
Loading