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 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(); }); });