2026-10-16 07:40:29 -06:00
|
|
|
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";
|
2026-10-15 23:08:47 -06:00
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
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 } },
|
|
|
|
|
};
|
2026-10-15 23:08:47 -06:00
|
|
|
|
|
|
|
|
describe("routes", () => {
|
2026-10-16 07:40:29 -06:00
|
|
|
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();
|
2026-10-15 23:08:47 -06:00
|
|
|
expect(screen.getByRole("img", { name: "PESCO" })).toBeInTheDocument();
|
2026-10-16 07:40:29 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("about shows the version", async () => {
|
|
|
|
|
mockFetch({ "/api/auth/me": NO_SESSION, "/api/version": { body: { version: "0.0.1" } }, "/healthz": { body: "ok" } });
|
|
|
|
|
renderApp("/about");
|
2026-10-15 23:08:47 -06:00
|
|
|
expect(await screen.findByText("0.0.1")).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
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();
|
2026-10-15 23:08:47 -06:00
|
|
|
});
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
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();
|
2026-10-16 09:31:45 -06:00
|
|
|
expect(screen.getByText("Projects and job packets")).toBeInTheDocument();
|
2026-10-16 07:40:29 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
2026-10-15 23:08:47 -06:00
|
|
|
});
|
|
|
|
|
});
|