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:
2026-10-16 07:40:29 -06:00
co-authored by Claude Fable 5.1
parent 0b663bb2b2
commit 97b0973130
95 changed files with 9609 additions and 183 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/
+60
View File
@@ -0,0 +1,60 @@
{
"name": "packetstation-e2e",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "packetstation-e2e",
"version": "0.0.0",
"devDependencies": {
"@playwright/test": "1.64.1"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "packetstation-e2e",
"version": "0.0.0",
"private": true,
"description": "Playwright scenarios against the CI stack and its Samba DC.",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.64.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
// Runs inside the playwright service of deploy/compose.ci.yaml.
// E2E_BASE_URL points at the app on the backend network. The scenarios share state
// (a registered device, a PIN), so they run in order on one worker.
export default defineConfig({
testDir: "./tests",
timeout: 60_000,
expect: { timeout: 10_000 },
retries: 0,
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]],
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://ps-app:8000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+93
View File
@@ -0,0 +1,93 @@
import { readFileSync } from "node:fs";
import { expect, type Locator, type Page } from "@playwright/test";
// users.json is written by deploy/samba/seed.sh on the ci-dc-export volume.
// Shape: {"domain": "pstest.local", "users": {"<sam>": {"password", "emp_no", "display_name"}}}
export interface DcUser {
password: string;
emp_no: string | null;
display_name: string;
}
let cache: Record<string, DcUser> | null = null;
export function dcUser(sam: string): DcUser & { sam: string } {
if (!cache) {
const file = process.env.E2E_DC_USERS_FILE;
if (!file) throw new Error("E2E_DC_USERS_FILE is not set");
cache = (JSON.parse(readFileSync(file, "utf-8")) as { users: Record<string, DcUser> }).users;
}
const u = cache[sam];
if (!u) throw new Error(`user ${sam} is not in users.json`);
return { sam, ...u };
}
export const PIN = "246813";
/** The top bar. */
export function topBar(page: Page): Locator {
return page.getByRole("banner");
}
/** A tab by name. Tabs may be drawn as role=tab or as plain buttons. */
export function tab(page: Page, name: RegExp): Locator {
return page.getByRole("tab", { name }).or(page.getByRole("button", { name })).first();
}
/** The card, section or form that holds a heading matching `heading`. */
export function section(page: Page, heading: RegExp): Locator {
return page
.locator("section, .card, form, fieldset")
.filter({ has: page.getByRole("heading", { name: heading }) })
.last();
}
export async function openSignIn(page: Page): Promise<void> {
await page.goto("/signin");
await expect(page.getByRole("heading", { name: /sign in/i })).toBeVisible();
}
/** Fill and submit the password tab. Does not wait for the outcome. */
export async function submitPassword(page: Page, login: string, password: string): Promise<void> {
await openSignIn(page);
const pwTab = page.getByRole("tab", { name: /password/i });
if (await pwTab.count()) await pwTab.first().click();
await page.getByLabel(/user ?name|account/i).first().fill(login);
await page.getByLabel(/^password$/i).fill(password);
await page.getByRole("button", { name: /^sign in$/i }).click();
}
/** Password sign-in that must succeed. */
export async function signInPassword(page: Page, sam: string): Promise<void> {
const u = dcUser(sam);
await submitPassword(page, u.sam, u.password);
await expect(page).not.toHaveURL(/\/signin/);
await expect(topBar(page).getByText(u.display_name)).toBeVisible();
}
export async function signOut(page: Page): Promise<void> {
const bar = topBar(page);
await bar
.getByRole("button", { name: /sign out/i })
.or(bar.getByRole("link", { name: /sign out/i }))
.first()
.click();
await expect(page).toHaveURL(/\/signin/);
}
/**
* Enter a PIN with the on-screen keypad. Focuses `field` first so the keypad targets it.
* If the keypad left the field short (it fed another field), fills the field directly.
*/
export async function enterPin(scope: Page | Locator, field: Locator, pin: string): Promise<void> {
await field.click();
const keypad = scope.getByLabel(/keypad/i).first();
if (await keypad.count()) {
for (const d of pin) {
await keypad.getByRole("button", { name: new RegExp(`^(Digit )?${d}$`) }).click();
}
}
if ((await field.inputValue()) !== pin) {
await field.fill(pin);
}
}
+162
View File
@@ -0,0 +1,162 @@
import { expect, test } from "@playwright/test";
import {
PIN,
dcUser,
enterPin,
openSignIn,
section,
signInPassword,
signOut,
submitPassword,
tab,
topBar,
} from "./helpers";
// Phase 1 sign-in scenarios against the CI stack and the Samba DC (PSTEST.LOCAL).
// Scenarios 1 to 5 stand alone. 6 registers a device and sets weld.andy's PIN, and 7 and 8
// rely on it, so those three run as one serial group. The config runs one worker, in file order.
test("1. a fresh browser has no PIN tab", async ({ page }) => {
await openSignIn(page);
await expect(page.getByRole("tab", { name: /password/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /^PIN$/ })).toHaveCount(0);
});
test("2. a wrong password shows Sign-in failed", async ({ page }) => {
await submitPassword(page, "clerk.clara", `${dcUser("clerk.clara").password}-wrong`);
await expect(page.getByText(/Sign-in failed/)).toBeVisible();
await expect(page).toHaveURL(/\/signin/);
});
test("3. a user with no PS group sees the no-role message", async ({ page }) => {
const u = dcUser("norole.nick");
await submitPassword(page, u.sam, u.password);
await expect(page.getByText(/No role in Packet Station/)).toBeVisible();
await expect(page).toHaveURL(/\/signin/);
});
test("4. clerk.clara lands on Clerical with no role switcher", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/Arrives in phase/i)).toBeVisible();
await expect(page.locator(".roles")).toHaveCount(0);
await expect(topBar(page).getByRole("button", { name: /^Supervisor$/ })).toHaveCount(0);
});
test("5. multi.mia switches between Clerical and Supervisor", async ({ page }) => {
await signInPassword(page, "multi.mia");
const switcher = page.locator(".roles");
await expect(switcher).toBeVisible();
const choice = (name: RegExp) =>
switcher.getByRole("button", { name }).or(switcher.getByRole("link", { name })).first();
await expect(choice(/^Clerical$/)).toBeVisible();
await expect(choice(/^Supervisor$/)).toBeVisible();
await choice(/^Supervisor$/).click();
await expect(page).toHaveURL(/\/supervisor/);
await choice(/^Clerical$/).click();
await expect(page).toHaveURL(/\/clerical/);
});
test.describe.serial("device, PIN and admin", () => {
test("6. admin registers a device, weld.andy sets a PIN and signs in with it", async ({ page }) => {
// Admin registers this browser.
await signInPassword(page, "admin.ann");
await page.goto("/account");
const device = section(page, /register this device/i);
await device.getByRole("textbox").first().fill("CI tablet");
const registered = page.waitForResponse(
(r) => r.url().endsWith("/api/account/device") && r.request().method() === "POST",
);
await device.getByRole("button", { name: /register/i }).click();
expect((await registered).ok()).toBe(true);
await signOut(page);
// The device cookie now shows the PIN tab.
await expect(page.getByRole("tab", { name: /^PIN$/ })).toBeVisible();
// weld.andy sets a PIN with his password.
const andy = dcUser("weld.andy");
await signInPassword(page, andy.sam);
await page.goto("/account");
const pin = section(page, /\bPIN\b/);
await pin.getByLabel(/password/i).fill(andy.password);
await pin.getByLabel(/^(new )?PIN$/i).fill(PIN);
await pin.getByLabel(/confirm/i).fill(PIN);
const saved = page.waitForResponse(
(r) => r.url().endsWith("/api/account/pin") && r.request().method() === "POST",
);
await pin.getByRole("button", { name: /(create|change|save) PIN|^save$/i }).click();
expect((await saved).ok()).toBe(true);
await signOut(page);
// PIN sign-in on the registered device.
await openSignIn(page);
await page.getByRole("tab", { name: /^PIN$/ }).click();
await page.getByRole("textbox", { name: /employee id/i }).fill(andy.emp_no ?? "");
await enterPin(page, page.getByRole("textbox", { name: /^PIN$/ }), PIN);
await page.getByRole("button", { name: /^sign in$/i }).click();
await expect(page).toHaveURL(/\/stall/);
await expect(topBar(page).getByText(andy.display_name)).toBeVisible();
// Nothing admin-ish for a shop worker.
await page.goto("/admin");
await expect(page.getByRole("tab", { name: /group map/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /refresh from ad/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /clear pin/i })).toHaveCount(0);
});
test("7. admin sees the group map report, the device and the AD refresh counts", async ({ page }) => {
await signInPassword(page, "admin.ann");
await page.goto("/admin");
await tab(page, /group map/i).click();
const missing = page.getByRole("row").filter({ hasText: "PS-14-Electrical-Lead" });
await expect(missing).toBeVisible();
await expect(missing.locator(".pill.st-bad").or(missing.getByText(/missing/i)).first()).toBeVisible();
await expect(page.getByText("PS-Extra-Legacy").first()).toBeVisible();
const refreshed = page.waitForResponse(
(r) => r.url().endsWith("/api/admin/ad/refresh") && r.request().method() === "POST",
);
await page.getByRole("button", { name: /refresh from ad now/i }).click();
const resp = await refreshed;
expect(resp.ok()).toBe(true);
const counts = (await resp.json()) as { users_seen: number; emp_duplicate: string[] };
expect(counts.users_seen).toBeGreaterThanOrEqual(15);
expect(counts.emp_duplicate).toContain("9001");
await expect(page.getByText(/9001/).first()).toBeVisible();
await tab(page, /devices/i).click();
await expect(page.getByText("CI tablet").first()).toBeVisible();
});
test("8. three idle minutes lock the screen and the PIN unlocks it", async ({ page }) => {
// A hidden tab locks only after a minute away (the old build's rule), and a reload never
// locks, so the scenario drives the idle lock with a fake clock instead: the client's
// 3-minute idle timer posts /api/auth/lock and the server locks the session.
await page.clock.install();
const andy = dcUser("weld.andy");
await signInPassword(page, andy.sam);
await expect(page).toHaveURL(/\/stall/);
const locked = page.waitForResponse(
(r) => r.url().endsWith("/api/auth/lock") && r.request().method() === "POST",
);
await page.clock.runFor(3 * 60_000 + 20_000);
expect((await locked).status()).toBe(204);
const lock = page.getByRole("dialog").or(page.locator(".scrim.lock")).first();
await expect(lock).toBeVisible();
await expect(lock.getByText(andy.display_name)).toBeVisible();
// The server refuses a locked session until the step-up unlock.
const probe = await page.request.get("/api/account");
expect(probe.status()).toBe(423);
await enterPin(lock, lock.getByRole("textbox", { name: /^PIN$/ }), PIN);
await lock.getByRole("button", { name: /unlock/i }).click();
await expect(lock).toBeHidden();
await expect(page).toHaveURL(/\/stall/);
await expect(topBar(page).getByText(andy.display_name)).toBeVisible();
});
});