Phase 1: identity, sessions, audit, frontend shell
AD sign-in over LDAPS (search with the service account, bind as the user, nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused), roles as the union of PS-* groups mapped by a table derived from the seeded departments. Server-side sessions with idle, lock and absolute timeouts, Origin and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the domain password; PIN sign-in by employee ID on registered devices only; five wrong attempts disable the PIN, three wrong at unlock or signature end the session. Rate limits per account and per address before any bind. Devices, signatures table and sign helper, audit rows for every identity event, SSE over LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens (AD view, devices, PIN clearing, group map with missing-group flags, refresh from AD now). CLI ad-groups --list and ad-refresh. Migration 0002. Frontend: real sign-in (password tab, PIN keypad on registered devices), lock screen, role switcher, Account settings, Admin shell, screen placeholders. CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50 groups plus one extra, 17 test users; wired into the CI compose overlay and the Gitea workflow; Playwright sign-in scenario. Verified on docker-1: 171 server tests green inside the CI stack against PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against the live stack (password and PIN sign-in, lock and unlock, Admin group map). Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks only after a minute as the old build did, and the app uses the alias ps-app for Playwright because "app" is on Chromium's HSTS preload list. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
+295
-24
@@ -1,35 +1,306 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mockFetch } from "./test/fetchMock";
|
||||
import { AppRoutes } from "./App";
|
||||
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";
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
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 shows the top bar and the version", async () => {
|
||||
mockFetch({ "/api/version": { body: { version: "0.0.1" } }, "/healthz": { body: "ok" } });
|
||||
renderAt("/");
|
||||
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("sign-in shows both tabs with disabled inputs", () => {
|
||||
renderAt("/signin");
|
||||
expect(screen.getByRole("tab", { name: "Domain password" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "PIN" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Password")).toBeDisabled();
|
||||
expect(screen.getByText(/phase 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("unknown paths show the 404 page", () => {
|
||||
renderAt("/no/such/page");
|
||||
expect(screen.getByRole("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("This screen arrives in phase 2.")).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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user