Files
PacketStation/frontend/src/App.test.tsx
T

307 lines
15 KiB
TypeScript
Raw Normal View History

import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DEPARTMENTS, NO_SESSION, makeMe, renderApp } from "./test/auth";
import { callsTo, mockFetch } from "./test/fetchMock";
const NO_DEVICE = { body: { device: null, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } } };
const WITH_DEVICE = {
body: { device: { id: "dev-1", name: "Weld Parts Stall 3" }, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } },
};
describe("routes", () => {
it("home sends a signed-out visitor to sign-in", async () => {
mockFetch({ "/api/auth/me": NO_SESSION, "/api/auth/context": NO_DEVICE });
renderApp("/");
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "PESCO" })).toBeInTheDocument();
});
it("about shows the version", async () => {
mockFetch({ "/api/auth/me": NO_SESSION, "/api/version": { body: { version: "0.0.1" } }, "/healthz": { body: "ok" } });
renderApp("/about");
expect(await screen.findByText("0.0.1")).toBeInTheDocument();
});
it("unknown deep paths show the 404 page", async () => {
mockFetch({ "/api/auth/me": NO_SESSION });
renderApp("/no/such/page");
expect(await screen.findByRole("heading", { name: "Page not found" })).toBeInTheDocument();
});
it("a screen route without a session goes to sign-in", async () => {
mockFetch({ "/api/auth/me": NO_SESSION, "/api/auth/context": NO_DEVICE });
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
});
it("a disallowed screen goes to the user's first screen", async () => {
mockFetch({ "/api/auth/me": { body: makeMe() } });
renderApp("/admin");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
expect(screen.getByText("Projects and job packets")).toBeInTheDocument();
});
});
describe("sign-in", () => {
it("posts the password and lands on the user's first screen", async () => {
const me = makeMe({ roles: ["shop:03:supervisor"], screens: ["supervisor", "stall"] });
const fetch = mockFetch({
"/api/auth/me": NO_SESSION,
"/api/auth/context": NO_DEVICE,
"POST /api/auth/signin/password": { body: me },
"/api/departments": { body: DEPARTMENTS },
});
renderApp("/signin");
fireEvent.change(await screen.findByLabelText("User name"), { target: { value: " sup.wes " } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret-pass" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("heading", { name: "Supervisor" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/auth/signin/password")).toEqual([{ login: "sup.wes", password: "secret-pass" }]);
expect(screen.getByText("Clara Clerk")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Account" })).toBeInTheDocument();
});
it("shows the server message on a failed sign-in", async () => {
mockFetch({
"/api/auth/me": NO_SESSION,
"/api/auth/context": NO_DEVICE,
"POST /api/auth/signin/password": {
status: 403,
body: { detail: "No role in Packet Station. Ask IT to add you to a group.", code: "no_role" },
},
});
renderApp("/signin");
fireEvent.change(await screen.findByLabelText("User name"), { target: { value: "norole.nick" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "x" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent("No role in Packet Station. Ask IT to add you to a group.");
});
it("shows the retry time on 429", async () => {
mockFetch({
"/api/auth/me": NO_SESSION,
"/api/auth/context": NO_DEVICE,
"POST /api/auth/signin/password": { status: 429, body: { detail: "Too many attempts", code: "rate_limited", retry_after: 150 } },
});
renderApp("/signin");
fireEvent.change(await screen.findByLabelText("User name"), { target: { value: "a" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "b" } });
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Try again in 3 minutes.");
});
it("hides the PIN tab without a registered device", async () => {
const fetch = mockFetch({ "/api/auth/me": NO_SESSION, "/api/auth/context": NO_DEVICE });
renderApp("/signin");
expect(await screen.findByRole("tab", { name: "Domain password" })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(callsTo(fetch, "GET", "/api/auth/context")).toHaveLength(1));
expect(screen.queryByRole("tab", { name: "PIN" })).not.toBeInTheDocument();
});
it("shows the PIN tab on a registered device and types with the keypad", async () => {
const me = makeMe({ session: { kind: "pin" } });
const fetch = mockFetch({
"/api/auth/me": NO_SESSION,
"/api/auth/context": WITH_DEVICE,
"POST /api/auth/signin/pin": { body: me },
});
renderApp("/signin");
const pinTab = await screen.findByRole("tab", { name: "PIN" });
expect(pinTab).toHaveAttribute("aria-selected", "true");
expect(screen.getByText(/Weld Parts Stall 3/)).toBeInTheDocument();
// The keypad types into the employee ID first.
for (const d of ["0", "4", "0", "0", "1"]) fireEvent.click(screen.getByRole("button", { name: `Digit ${d}` }));
expect(screen.getByLabelText("Employee ID")).toHaveValue("04001");
// Then into the PIN once it has the focus.
fireEvent.focus(screen.getByLabelText("PIN", { selector: "input" }));
for (const d of ["1", "2", "3", "4", "5", "6", "7"]) fireEvent.click(screen.getByRole("button", { name: `Digit ${d}` }));
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByLabelText("PIN", { selector: "input" })).toHaveValue("123456");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/auth/signin/pin")).toEqual([{ emp_no: "04001", pin: "123456" }]);
});
it("redirects away from sign-in when already signed in", async () => {
mockFetch({ "/api/auth/me": { body: makeMe() } });
renderApp("/signin");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
});
});
describe("lock screen", () => {
it("appears on 423 from /api/auth/me and a right password hides it", async () => {
const fetch = mockFetch({
"/api/auth/me": { status: 423, body: { detail: "Locked", code: "locked" } },
"POST /api/auth/unlock": { body: makeMe() },
});
renderApp("/clerical");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(within(dialog).getByText("Locked")).toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("Password"), { target: { value: "pw" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(callsTo(fetch, "POST", "/api/auth/unlock")).toEqual([{ factor: "password", secret: "pw" }]);
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
});
it("uses the PIN keypad, warns after a miss, and ends on session_ended", async () => {
let tries = 0;
mockFetch({
"/api/auth/me": { body: makeMe({ pin: { set: true, disabled: false }, session: { locked: true } }) },
"/api/auth/context": NO_DEVICE,
"POST /api/auth/unlock": () =>
++tries < 2
? { status: 401, body: { detail: "Wrong PIN.", code: "bad_secret" } }
: { status: 401, body: { detail: "Signed out after three wrong attempts.", code: "session_ended" } },
});
renderApp("/clerical");
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByRole("heading", { name: "Clara Clerk" })).toBeInTheDocument();
for (const d of ["9", "9", "9", "9", "9", "9"]) fireEvent.click(within(dialog).getByRole("button", { name: `Digit ${d}` }));
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
expect(await within(dialog).findByText("Wrong PIN.")).toBeInTheDocument();
expect(within(dialog).getByText("Three wrong attempts end the session.")).toBeInTheDocument();
for (const d of ["1", "1", "1", "1", "1", "1"]) fireEvent.click(within(dialog).getByRole("button", { name: `Digit ${d}` }));
fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" }));
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
expect(screen.getByText("Signed out after three wrong attempts.")).toBeInTheDocument();
});
it("locks after the idle time and tells the server", async () => {
const fetch = mockFetch({ "/api/auth/me": { body: makeMe() }, "POST /api/auth/lock": { status: 204 } });
renderApp("/clerical", { idleLockMs: 30, idleCheckMs: 10 });
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/auth/lock")).toHaveLength(1);
});
function setHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden });
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
it("a short hidden spell does not lock, and both reports are posted", async () => {
const fetch = mockFetch({ "/api/auth/me": { body: makeMe() }, "POST /api/auth/visibility": { status: 204 } });
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
setHidden(true);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
setHidden(false);
await waitFor(() => expect(callsTo(fetch, "GET", "/api/auth/me")).toHaveLength(2));
expect(callsTo(fetch, "POST", "/api/auth/visibility")).toEqual([{ hidden: true }, { hidden: false }]);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
Reflect.deleteProperty(document, "hidden");
});
it("a tab hidden over 60 s comes back locked", async () => {
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
let returned = false;
const fetch = mockFetch({
"/api/auth/me": () => ({ body: makeMe({ session: { locked: returned } }) }),
"POST /api/auth/visibility": ({ body }) => {
if ((body as { hidden: boolean }).hidden === false) returned = true;
return { status: 204 };
},
});
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
setHidden(true);
now += 61_000;
setHidden(false);
// Locked at once, before the server answers, and still locked once Me says so.
expect(screen.getByRole("dialog")).toBeInTheDocument();
await waitFor(() => expect(callsTo(fetch, "GET", "/api/auth/me")).toHaveLength(2));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/auth/visibility")).toEqual([{ hidden: true }, { hidden: false }]);
Reflect.deleteProperty(document, "hidden");
});
it("locks on 423 from any other call", async () => {
mockFetch({
"/api/auth/me": { body: makeMe() },
"/api/account": { status: 423, body: { detail: "Locked", code: "locked" } },
});
renderApp("/account");
expect(await screen.findByRole("dialog")).toBeInTheDocument();
});
});
describe("role switcher", () => {
it("shows only the user's screens and switches between them", async () => {
mockFetch({
"/api/auth/me": { body: makeMe({ roles: ["clerical", "shop:06:supervisor"], screens: ["clerical", "supervisor", "stall"] }) },
});
renderApp("/clerical");
const nav = await screen.findByRole("navigation", { name: "Screens" });
const buttons = within(nav).getAllByRole("button");
expect(buttons.map((b) => b.textContent)).toEqual(["Clerical", "Supervisor", "Weld Stall"]);
expect(within(nav).getByRole("button", { name: "Clerical" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(within(nav).getByRole("button", { name: "Weld Stall" }));
expect(await screen.findByRole("heading", { name: "Weld Stall" })).toBeInTheDocument();
expect(within(nav).getByRole("button", { name: "Weld Stall" })).toHaveAttribute("aria-pressed", "true");
});
it("is hidden for a user with one screen", async () => {
mockFetch({ "/api/auth/me": { body: makeMe() } });
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Screens" })).not.toBeInTheDocument();
});
it("signs out from the top bar", async () => {
const fetch = mockFetch({
"/api/auth/me": { body: makeMe() },
"POST /api/auth/signout": { status: 204 },
"/api/auth/context": NO_DEVICE,
});
renderApp("/clerical");
fireEvent.click(await screen.findByRole("button", { name: "Sign out" }));
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/auth/signout")).toHaveLength(1);
});
});
describe("events", () => {
it("a session revoked event for this session signs out", async () => {
const sources: FakeEventSource[] = [];
class FakeEventSource {
url: string;
listeners = new Map<string, (ev: MessageEvent) => void>();
constructor(url: string) {
this.url = url;
sources.push(this);
}
addEventListener(type: string, fn: (ev: MessageEvent) => void) {
this.listeners.set(type, fn);
}
close() {}
emit(type: string, data: unknown) {
this.listeners.get(type)?.(new MessageEvent(type, { data: JSON.stringify(data) }));
}
}
vi.stubGlobal("EventSource", FakeEventSource);
mockFetch({ "/api/auth/me": { body: makeMe() }, "/api/auth/context": NO_DEVICE });
renderApp("/clerical");
expect(await screen.findByRole("heading", { name: "Clerical" })).toBeInTheDocument();
const es = sources.at(-1);
expect(es?.url).toBe("/api/events");
act(() => es?.emit("session", { session_id: "other", action: "revoked" }));
expect(screen.getByRole("heading", { name: "Clerical" })).toBeInTheDocument();
act(() => es?.emit("session", { session_id: "sess-1", action: "revoked" }));
expect(await screen.findByRole("heading", { name: "Sign in" })).toBeInTheDocument();
expect(screen.getByText("This session was signed out from another place.")).toBeInTheDocument();
});
});