Phase 9: Admin completion, reconcile, notifications, integrity, backups
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s

Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, badSignin, csvFrom, me, openAdminTab, parseCsv, runId } from "./adminhelpers";
// Phase 9 Admin, Access log tab, on the CI stack. The log lists every sign-in attempt and the audit's sign-in and
// session rows for everyone. admin.ann filters it by user (the API's user_id, and the tab's name or account box).
// A failed sign-in typed as "=e2e<run>" puts a formula-looking account in the log; the CSV download of a search for
// it has that cell guarded with a leading apostrophe, so a spreadsheet shows it as text.
const RUN = runId().toLowerCase();
const FORMULA = `=e2e${RUN}`;
test.describe.serial("admin access log", () => {
test.setTimeout(120_000);
test("filter by user: only that user's rows", async ({ browser }) => {
const ann = await admin(browser);
const self = await me(ann);
const out = await api(ann, "get", `/api/admin/access-log?user_id=${encodeURIComponent(String(self.id))}&limit=100`);
const rows = out.rows as Json[];
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) expect(String(r.user_id)).toBe(String(self.id));
// her own password sign-in is there, as an attempt that went through
expect(rows.some((r) => r.source === "attempt" && r.ok === true)).toBe(true);
const page = ann.page;
await openAdminTab(page, "accesslog");
const filters = page.getByRole("search", { name: "Access log filters" });
await filters.getByLabel("Name or account", { exact: true }).fill("admin.ann");
const searched = page.waitForResponse((r) => r.url().includes("/api/admin/access-log?") && r.status() === 200);
await filters.getByRole("button", { name: "Search access log", exact: true }).click();
await searched;
const body = page.getByRole("table", { name: "Access log" }).locator("tbody tr");
await expect(body.first()).toBeVisible();
const texts = await body.allTextContents();
expect(texts.length).toBeGreaterThan(0);
for (const t of texts) expect(t).toMatch(/Ann Admin|admin\.ann/);
await closeAll(ann);
});
test("CSV export: a cell that starts with = is guarded", async ({ browser }) => {
// a failed sign-in with a formula-looking account name, from a browser nobody is signed in on
const ctx = await browser.newContext();
const anon = await ctx.newPage();
expect(await badSignin(anon, FORMULA)).toBe(401);
await ctx.close();
const ann = await admin(browser);
const out = await api(ann, "get", `/api/admin/access-log?q=${encodeURIComponent(FORMULA)}&ok=false`);
expect((out.rows as Json[]).some((r) => r.account === FORMULA && r.ok === false)).toBe(true);
const page = ann.page;
await openAdminTab(page, "accesslog");
const filters = page.getByRole("search", { name: "Access log filters" });
await filters.getByLabel("Name or account", { exact: true }).fill(FORMULA);
await filters.getByLabel("Result", { exact: true }).selectOption("false");
const searched = page.waitForResponse((r) => r.url().includes("/api/admin/access-log?") && r.status() === 200);
await filters.getByRole("button", { name: "Search access log", exact: true }).click();
await searched;
const row = page.getByRole("table", { name: "Access log" }).getByRole("row").filter({ hasText: FORMULA });
await expect(row.first()).toBeVisible();
await expect(row.first()).toContainText("Failed");
const link = filters.getByRole("link", { name: "Download CSV", exact: true });
await expect(link).toHaveAttribute("href", /\/api\/admin\/access-log\.csv\?/);
const text = await csvFrom(page, link);
const cells = parseCsv(text).flat();
expect(cells).toContain(`'${FORMULA}`);
expect(cells.filter((c) => /^[=+\-@]/.test(c) && c !== "-")).toEqual([]);
await closeAll(ann);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from "@playwright/test";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, call, openAdminTab, runId } from "./adminhelpers";
// Phase 9 Admin, Departments tab, on the CI stack. admin.ann adds a shop department with a code and short name
// made for this run and sees the three AD group names IT must create. Then she renames it and a station in the
// edit form; the code and short name have no field there, and the API refuses them in a change (422). A repeated
// code is refused (409). Last, the department is set inactive through the API so it stays out of the other
// scenarios. Existing departments are never changed: other specs read their names and stations.
const RUN = runId();
const CODE = `Z${RUN.slice(-3)}`;
const SLUG = `Ci${RUN}`;
const NAME = `CI Paint ${RUN}`;
const NAME2 = `CI Paint renamed ${RUN}`;
const GROUPS = ["Worker", "Lead", "Supervisor"].map((l) => `PS-${CODE}-${SLUG}-${l}`);
test.describe.serial("admin departments", () => {
test.setTimeout(120_000);
test("the list shows the seeded departments with their groups", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "departments");
const table = page.getByRole("table", { name: "Departments" });
await expect(table).toBeVisible();
const weld = table.getByRole("row").filter({ hasText: "Weld Parts" });
await expect(weld).toBeVisible();
await expect(weld).toContainText("03");
await expect(weld).toContainText("PS-03-WeldParts-Worker");
await expect(weld).toContainText("Stall 1");
const list = (await api(ann, "get", "/api/admin/departments")) as unknown as Json[];
const d03 = list.find((d) => d.code === "03");
expect(d03).toBeTruthy();
expect(d03?.slug).toBe("WeldParts");
expect((d03?.groups as Json[]).map((g) => g.name)).toEqual(
expect.arrayContaining(["PS-03-WeldParts-Worker", "PS-03-WeldParts-Lead", "PS-03-WeldParts-Supervisor"]),
);
await closeAll(ann);
});
test("add a department: the three group names IT must create", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "departments");
await page.getByRole("button", { name: "New department", exact: true }).click();
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Add department" }) });
await expect(form).toBeVisible();
await form.getByLabel("Code", { exact: true }).fill(CODE);
await form.getByLabel("Short name", { exact: true }).fill(SLUG);
await form.getByLabel("Name", { exact: true }).fill(NAME);
await form.getByLabel("Kind", { exact: true }).selectOption("shop");
await form.getByLabel("Stations, one per line").fill("Bay 1\nBay 2");
// the form previews the group names before saving
const preview = form.getByText(/IT will need to create these groups/);
for (const g of GROUPS) await expect(preview).toContainText(g);
const posted = page.waitForResponse(
(r) => r.url().endsWith("/api/admin/departments") && r.request().method() === "POST",
);
await form.getByRole("button", { name: "Add department", exact: true }).click();
const resp = await posted;
expect(resp.status(), await resp.text()).toBe(201);
const out = (await resp.json()) as Json;
expect([...(out.groups_to_create as string[])].sort()).toEqual([...GROUPS].sort());
expect(out.department.code).toBe(CODE);
expect(out.department.slug).toBe(SLUG);
const note = page.getByRole("status", { name: "Groups to create" });
await expect(note).toBeVisible();
for (const g of GROUPS) await expect(note).toContainText(g);
const row = page.getByRole("table", { name: "Departments" }).getByRole("row").filter({ hasText: NAME });
await expect(row).toBeVisible();
await expect(row).toContainText(CODE);
for (const g of GROUPS) await expect(row).toContainText(g);
await expect(row).toContainText("Bay 1, Bay 2");
// the same code again is refused
const again = await call(ann, "post", "/api/admin/departments", {
code: CODE,
slug: `${SLUG}x`,
name: `${NAME} again`,
kind: "shop",
weld: false,
supervisor_group: true,
stations: [],
});
expect(again.status, JSON.stringify(again.body)).toBe(409);
await closeAll(ann);
});
test("edit its name and a station; code and short name are not editable", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "departments");
await page.getByRole("button", { name: `Edit department ${CODE}`, exact: true }).click();
const editor = page.locator("form").filter({ has: page.getByRole("heading", { name: `Edit ${CODE} ${NAME}` }) });
await expect(editor).toBeVisible();
// no field for the code or the short name
await expect(editor.getByLabel("Code", { exact: true })).toHaveCount(0);
await expect(editor.getByLabel("Short name", { exact: true })).toHaveCount(0);
await editor.getByLabel("Department name", { exact: true }).fill(NAME2);
await editor.getByLabel("Station 1 name", { exact: true }).fill("Bay 1A");
const patched = page.waitForResponse(
(r) => r.url().endsWith(`/api/admin/departments/${CODE}`) && r.request().method() === "PATCH",
);
await editor.getByRole("button", { name: "Save department", exact: true }).click();
const resp = await patched;
expect(resp.status(), await resp.text()).toBe(200);
await expect(page.getByRole("status").filter({ hasText: "Department saved." })).toBeVisible();
const row = page.getByRole("table", { name: "Departments" }).getByRole("row").filter({ hasText: NAME2 });
await expect(row).toBeVisible();
await expect(row).toContainText("Bay 1A, Bay 2");
const list = (await api(ann, "get", "/api/admin/departments")) as unknown as Json[];
const d = list.find((x) => x.code === CODE);
expect(d?.name).toBe(NAME2);
expect(d?.slug).toBe(SLUG);
expect((d?.stations as Json[]).map((s) => s.name)).toEqual(expect.arrayContaining(["Bay 1A", "Bay 2"]));
expect((d?.stations as Json[]).map((s) => s.name)).not.toContain("Bay 1");
// the API refuses a code or a short name in a change
for (const body of [{ code: "ZZZZ" }, { slug: "Other" }]) {
const r = await call(ann, "patch", `/api/admin/departments/${CODE}`, body);
expect(r.status, `${JSON.stringify(body)}: ${JSON.stringify(r.body)}`).toBe(422);
}
// two stations with one name are refused
const ids = (d?.stations as Json[]).map((s) => Number(s.id));
const dup = await call(ann, "patch", `/api/admin/departments/${CODE}`, {
stations: [
{ id: ids[0], name: "Same", sort: 1, active: true },
{ id: ids[1], name: "Same", sort: 2, active: true },
],
});
expect(dup.status, JSON.stringify(dup.body)).toBe(422);
await closeAll(ann);
});
test("cleanup: the CI department is set inactive", async ({ browser }) => {
const ann = await admin(browser);
const out = await api(ann, "patch", `/api/admin/departments/${CODE}`, { active: false });
expect(out.active).toBe(false);
await openAdminTab(ann.page, "departments");
const row = ann.page.getByRole("table", { name: "Departments" }).getByRole("row").filter({ hasText: NAME2 });
await expect(row).toContainText("Inactive");
await closeAll(ann);
});
});
+254
View File
@@ -0,0 +1,254 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { api, closeAll, type Actor } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, call, me, openAdminTab, runId } from "./adminhelpers";
// Phase 9 Admin, Notifications tab, on the CI stack (NOTIFY_DRY_RUN=true, SMTP_HOST empty, no relay). Every event
// type has a rule seeded with the PS-Admins group. admin.ann opens "Change order waiting", switches it to the daily
// digest and back, adds a mailbox on the CI domain and excludes herself, which the API refuses on a security event
// type (422). A test send to an address on the CI domain always goes out, even in dry run; with no relay it fails
// with "Mail is off", and the sent log shows it as a failed test. The rule is put back as it was at the end.
const RUN = runId().toLowerCase();
const EVENT = "co_waiting";
const LABEL = "Change order waiting";
const MAILBOX = `e2e-${RUN}@pstest.local`;
const TEST_TO = "[email protected]";
const EVENT_TYPES = [
"security_alert",
"signin_burst",
"integrity_failure",
"feed_failed",
"feed_degraded",
"drawing_failing",
"ad_reconcile_report",
"group_missing",
"backup_stale",
"co_waiting",
"qc_reject",
"version_deployed",
"assets_quota",
];
let ORIGINAL: Json | null = null;
async function rules(a: Actor): Promise<Json[]> {
return (await api(a, "get", "/api/admin/notifications")).rules as Json[];
}
async function rule(a: Actor, eventType: string): Promise<Json> {
const r = (await rules(a)).find((x) => x.event_type === eventType);
if (!r) throw new Error(`no rule ${eventType}`);
return r;
}
/** The PUT body that writes `r` back as it is, with changes on top. */
function putBody(r: Json, over: Json = {}): Json {
return {
enabled: r.enabled,
delivery: r.delivery,
digest_time: r.digest_time,
param: r.param ? r.param.value : null,
recipients: (r.recipients as Json[]).map((x) => {
if (x.kind === "group") return { kind: "group", group_dn: x.group_dn, group_name: x.group_name };
if (x.kind === "user") return { kind: "user", user_id: x.user_id };
if (x.kind === "mailbox") return { kind: "mailbox", address: x.address };
return { kind: x.kind };
}),
exclusions: (r.exclusions as Json[]).filter((x) => !x.self).map((x) => x.user_id),
version: r.version,
...over,
};
}
function rulesTable(page: Page): Locator {
return page.getByRole("table", { name: "Notification rules" });
}
function ruleRow(page: Page, label: string): Locator {
return rulesTable(page)
.getByRole("row")
.filter({ has: page.getByRole("button", { name: `Edit ${label}`, exact: true }) });
}
/** Open a rule's editor from the table. */
async function editRule(page: Page, label: string): Promise<Locator> {
await page.getByRole("button", { name: `Edit ${label}`, exact: true }).click();
const editor = page.locator("section").filter({ has: page.getByRole("heading", { name: label, exact: true }) });
await expect(editor).toBeVisible();
return editor;
}
async function saveRule(page: Page, editor: Locator, eventType: string): Promise<Json> {
const put = page.waitForResponse(
(r) => r.url().endsWith(`/api/admin/notifications/${eventType}`) && r.request().method() === "PUT",
);
await editor.getByRole("button", { name: "Save rule", exact: true }).click();
const resp = await put;
expect(resp.status(), await resp.text()).toBe(200);
return (await resp.json()) as Json;
}
test.describe.serial("admin notifications", () => {
test.setTimeout(150_000);
test.afterAll(async ({ browser }) => {
if (!ORIGINAL) return;
const ann = await admin(browser);
const now = await rule(ann, EVENT);
await api(ann, "put", `/api/admin/notifications/${EVENT}`, putBody(ORIGINAL, { version: now.version }));
await closeAll(ann);
});
test("every event type has a rule, seeded with PS-Admins", async ({ browser }) => {
const ann = await admin(browser);
const out = await api(ann, "get", "/api/admin/notifications");
expect(out.dry_run).toBe(true);
const list = out.rules as Json[];
expect(list.map((r) => r.event_type)).toEqual(expect.arrayContaining(EVENT_TYPES));
for (const r of list) {
const admins = (r.recipients as Json[]).find((x) => x.kind === "group" && x.group_name === "PS-Admins");
expect(admins, `${r.event_type} has PS-Admins`).toBeTruthy();
expect(String(admins?.group_dn)).toMatch(/^CN=PS-Admins,OU=PacketStation,OU=Groups,/);
}
const security = list.filter((r) => r.security).map((r) => r.event_type);
expect(security.sort()).toEqual(["integrity_failure", "security_alert", "signin_burst"]);
ORIGINAL = await rule(ann, EVENT);
// the group search reads the CI directory
const groups = (await api(ann, "get", "/api/admin/notifications/groups?q=PS-Adm")) as unknown as Json[];
expect(groups.map((g) => g.name)).toContain("PS-Admins");
const page = ann.page;
await openAdminTab(page, "notifications");
await expect(page.getByRole("group", { name: "Mail settings" })).toContainText("Dry run");
await expect(rulesTable(page)).toBeVisible();
for (const label of ["Security alert", "Sign-in burst", LABEL, "Feed run failed"]) {
await expect(ruleRow(page, label)).toContainText("Group PS-Admins");
}
await expect(ruleRow(page, "Security alert")).toContainText("Security");
await closeAll(ann);
});
test("delivery: daily digest and back to at once", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "notifications");
let editor = await editRule(page, LABEL);
await editor.getByLabel("Delivery", { exact: true }).selectOption("digest");
await expect(editor.getByLabel("Digest time", { exact: true })).toBeEnabled();
await editor.getByLabel("Digest time", { exact: true }).fill("07:30");
let saved = await saveRule(page, editor, EVENT);
expect(saved.delivery).toBe("digest");
expect(saved.digest_time).toBe("07:30");
await expect(page.getByRole("status").filter({ hasText: `${LABEL}: saved.` })).toBeVisible();
await expect(ruleRow(page, LABEL)).toContainText("Daily digest at 07:30");
expect((await rule(ann, EVENT)).delivery).toBe("digest");
editor = page.locator("section").filter({ has: page.getByRole("heading", { name: LABEL, exact: true }) });
await editor.getByLabel("Delivery", { exact: true }).selectOption("immediate");
saved = await saveRule(page, editor, EVENT);
expect(saved.delivery).toBe("immediate");
await expect(ruleRow(page, LABEL)).toContainText("At once");
expect((await rule(ann, EVENT)).delivery).toBe("immediate");
// a stale version is refused
const old = putBody(saved, { version: Number(saved.version) - 1 });
const stale = await call(ann, "put", `/api/admin/notifications/${EVENT}`, old);
expect(stale.status, JSON.stringify(stale.body)).toBe(409);
await closeAll(ann);
});
test("a mailbox recipient and an exclusion; no exclusion on a security event", async ({ browser }) => {
const ann = await admin(browser);
const self = await me(ann);
const page = ann.page;
await openAdminTab(page, "notifications");
const editor = await editRule(page, LABEL);
await editor.getByLabel("Add a recipient", { exact: true }).selectOption("mailbox");
await editor.getByLabel("Mailbox address", { exact: true }).fill(MAILBOX);
await editor.getByRole("button", { name: "Add mailbox", exact: true }).click();
await expect(editor.getByRole("list", { name: "Recipients" })).toContainText(`Mailbox ${MAILBOX}`);
await editor.getByLabel("Find a user to exclude", { exact: true }).fill("admin.ann");
await editor.getByRole("button", { name: `Pick ${self.display_name}`, exact: true }).click();
await expect(editor.getByRole("list", { name: "Exclusions" })).toContainText(String(self.display_name));
const saved = await saveRule(page, editor, EVENT);
expect((saved.recipients as Json[]).some((r) => r.kind === "mailbox" && r.address === MAILBOX)).toBe(true);
expect((saved.recipients as Json[]).some((r) => r.group_name === "PS-Admins")).toBe(true);
expect((saved.exclusions as Json[]).map((x) => String(x.user_id))).toContain(String(self.id));
await expect(ruleRow(page, LABEL)).toContainText(`Mailbox ${MAILBOX}`);
// a bad address is refused by the API
const cur = await rule(ann, EVENT);
const badAddr = await call(
ann,
"put",
`/api/admin/notifications/${EVENT}`,
putBody(cur, { recipients: [...putBody(cur).recipients, { kind: "mailbox", address: "not an address" }] }),
);
expect(badAddr.status, JSON.stringify(badAddr.body)).toBe(422);
// security event types take no exclusion: the editor says so and the API refuses one
const sec = await rule(ann, "security_alert");
const refused = await call(
ann,
"put",
"/api/admin/notifications/security_alert",
putBody(sec, { exclusions: [String(self.id)] }),
);
expect(refused.status, JSON.stringify(refused.body)).toBe(422);
expect((await rule(ann, "security_alert")).exclusions).toEqual([]);
await page.getByRole("button", { name: "Edit Security alert", exact: true }).click();
const secHead = page.getByRole("heading", { name: "Security alert", exact: true });
const secEditor = page.locator("section").filter({ has: secHead });
await expect(secEditor.getByText("Nobody can be excluded or opt out.", { exact: false })).toBeVisible();
await expect(secEditor.getByLabel("Find a user to exclude", { exact: true })).toHaveCount(0);
await closeAll(ann);
});
test("a test send to a CI address fails with Mail is off and shows in the sent log", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "notifications");
const editor = await editRule(page, LABEL);
await editor.getByLabel("Send the test to", { exact: true }).fill(TEST_TO);
const sent = page.waitForResponse(
(r) => r.url().endsWith(`/api/admin/notifications/${EVENT}/test`) && r.request().method() === "POST",
);
await editor.getByRole("button", { name: "Send a test", exact: true }).click();
const resp = await sent;
expect(resp.status(), await resp.text()).toBe(200);
const out = (await resp.json()) as Json;
// a test always goes out, even in dry run; with no relay in CI it fails
expect(out.result).toBe("failed");
expect(String(out.error)).toContain("Mail is off");
const result = editor.getByRole("status", { name: "Test result" });
await expect(result).toContainText("failed");
await expect(result).toContainText("Mail is off");
await expect(result).toContainText(TEST_TO);
// the sent log, through the API and in the tab
const log = await api(ann, "get", `/api/admin/notifications/sent?event_type=${EVENT}&limit=5`);
const row = (log.rows as Json[])[0];
expect(row.test).toBe(true);
expect(row.result).toBe("failed");
expect(String(row.error)).toContain("Mail is off");
expect((row.recipients as Json[]).map((r) => r.address)).toEqual([TEST_TO]);
const table = page.getByRole("table", { name: "Sent log" });
const line = table.getByRole("row").filter({ hasText: LABEL }).filter({ hasText: "Mail is off" }).first();
await expect(line).toBeVisible();
await expect(line).toContainText("Test");
await expect(line).toContainText("failed");
await expect(line).toContainText(TEST_TO);
// a test to an address that is not one is refused by the API
const bad = await call(ann, "post", `/api/admin/notifications/${EVENT}/test`, { to: "nobody" });
expect([400, 422], JSON.stringify(bad.body)).toContain(bad.status);
await closeAll(ann);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
import { dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, me, openAdminTab } from "./adminhelpers";
// Phase 9 Admin, Who is in tab, on the CI stack. clerk.clara signs in and opens Clerical; admin.ann sees both live
// sessions in the list, with a password sign-in and Active. When clara signs out through the API her row is gone.
test.describe.serial("admin who is in", () => {
test.setTimeout(90_000);
test("live sessions are listed; a signed-out one drops out", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const claraName = dcUser("clerk.clara").display_name;
const claraSession = String((await api(clara, "get", "/api/auth/me")).session.id);
const ann = await admin(browser);
const annName = (await me(ann)).display_name as string;
const out = await api(ann, "get", "/api/admin/presence");
const sessions = out.sessions as Json[];
const mine = sessions.find((s) => s.display_name === annName);
expect(mine?.kind).toBe("password");
expect(mine?.locked).toBe(false);
expect(sessions.some((s) => String(s.session_id) === claraSession)).toBe(true);
const page = ann.page;
await openAdminTab(page, "presence");
const table = page.getByRole("table", { name: "Who is in" });
const annRow = table.getByRole("row").filter({ hasText: annName }).first();
await expect(annRow).toBeVisible();
await expect(annRow).toContainText("Password");
await expect(annRow).toContainText("Active");
await expect(table.getByRole("row").filter({ hasText: claraName }).first()).toBeVisible();
// clara signs out: her session leaves the list
await api(clara, "post", "/api/auth/signout", undefined, { status: 204 });
await expect
.poll(async () =>
((await api(ann, "get", "/api/admin/presence")).sessions as Json[]).some(
(s) => String(s.session_id) === claraSession,
),
)
.toBe(false);
await page.getByRole("button", { name: "Refresh who is in", exact: true }).click();
await expect
.poll(async () =>
(await table.getByRole("row").allTextContents()).filter((t) => t.includes(claraName)).length,
)
.toBeLessThan(sessions.filter((s) => s.display_name === claraName).length);
await closeAll(ann, clara);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { expect, test } from "@playwright/test";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, badSignin, call, openAdminTab } from "./adminhelpers";
// Phase 9 Admin, Security tab, on the CI stack. admin.ann lowers the sign-in burst threshold to its floor (5) in the
// settings form; a value under the floor is refused by the form and by the API (422). Then a fresh browser makes
// six wrong-password sign-ins for dup.dina, a CI account no other scenario signs in with: the account cooldown
// starts after five, and five failures in five minutes meet the threshold. The alerts list shows the cooldown and
// the burst. The integrity panel renders after a requested integrity run. The threshold is put back at the end.
const KEY = "signin_burst_threshold";
const LABEL = "Failed sign-ins across accounts in 5 minutes that raise an alert";
const TARGET = "dup.dina";
let ORIGINAL: number | null = null;
async function settings(a: Parameters<typeof api>[0]): Promise<Json> {
return api(a, "get", "/api/admin/security/settings");
}
test.describe.serial("admin security", () => {
test.setTimeout(180_000);
test.afterAll(async ({ browser }) => {
if (ORIGINAL === null) return;
const ann = await admin(browser);
const s = await settings(ann);
if (s.values[KEY] !== ORIGINAL) {
await api(ann, "put", "/api/admin/security/settings", { values: { [KEY]: ORIGINAL }, version: s.version });
}
await closeAll(ann);
});
test("a setting changes within its bounds; a value out of bounds is refused", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
let s = await settings(ann);
expect(s.bounds[KEY]).toEqual([5, 200]);
ORIGINAL = Number(s.values[KEY]);
if (ORIGINAL === 5) {
// a rerun after a failed one: start from the default so the form has a change to save
ORIGINAL = Number(s.defaults[KEY] ?? 20);
s = await api(ann, "put", "/api/admin/security/settings", { values: { [KEY]: ORIGINAL }, version: s.version });
}
// the API refuses a value under the floor and one over the ceiling
for (const v of [1, 201]) {
const r = await call(ann, "put", "/api/admin/security/settings", { values: { [KEY]: v }, version: s.version });
expect(r.status, `${v}: ${JSON.stringify(r.body)}`).toBe(422);
}
expect((await settings(ann)).values[KEY]).toBe(ORIGINAL);
await openAdminTab(page, "security");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Settings", exact: true }) });
const input = form.getByLabel(LABEL, { exact: true });
await expect(input).toHaveValue(String(ORIGINAL));
await expect(form.getByText("Allowed 5 to 200.").first()).toBeVisible();
// out of bounds: the form refuses it and nothing is sent
let puts = 0;
page.on("request", (r) => {
if (r.url().endsWith("/api/admin/security/settings") && r.method() === "PUT") puts++;
});
await input.fill("1");
await form.getByRole("button", { name: "Save security settings", exact: true }).click();
await expect(form.getByRole("alert")).toContainText("give a whole number from 5 to 200");
expect(puts).toBe(0);
// within bounds: saved
await input.fill("5");
const saved = page.waitForResponse(
(r) => r.url().endsWith("/api/admin/security/settings") && r.request().method() === "PUT",
);
await form.getByRole("button", { name: "Save security settings", exact: true }).click();
expect((await saved).status()).toBe(200);
await expect(form.getByRole("status")).toContainText("Security settings saved.");
expect((await settings(ann)).values[KEY]).toBe(5);
await closeAll(ann);
});
test("wrong passwords for one account: the alerts list shows the cooldown and the burst", async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const statuses: number[] = [];
for (let i = 0; i < 6; i++) statuses.push(await badSignin(page, TARGET));
// five wrong passwords reach the directory; the sixth is refused by the cooldown
expect(statuses.slice(0, 5)).toEqual([401, 401, 401, 401, 401]);
expect(statuses[5]).toBe(429);
await ctx.close();
const ann = await admin(browser);
const hit = (rows: Json[], kind: string, account?: string) =>
rows.some((r) => r.kind === kind && (account === undefined || String(r.account ?? "").toLowerCase() === account));
await expect
.poll(
async () => {
const rows = (await api(ann, "get", "/api/admin/security/alerts?hours=1")) as unknown as Json[];
return { lockout: hit(rows, "lockout", TARGET), burst: hit(rows, "signin_burst") };
},
{ timeout: 20_000 },
)
.toEqual({ lockout: true, burst: true });
const page2 = ann.page;
await openAdminTab(page2, "security");
const table = page2.getByRole("table", { name: "Security alerts" });
await expect(table).toBeVisible();
const cooldown = table.getByRole("row").filter({ hasText: "Account cooldowns" }).filter({ hasText: TARGET });
await expect(cooldown.first()).toBeVisible();
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" }).first()).toBeVisible();
// the kind chips filter the list
const chips = page2.getByRole("group", { name: "Alert kinds" });
const burstChip = chips.getByRole("button", { name: /^Sign-in bursts/ });
await expect(burstChip).toHaveAttribute("aria-pressed", "true");
await burstChip.click();
await expect(burstChip).toHaveAttribute("aria-pressed", "false");
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" })).toHaveCount(0);
await burstChip.click();
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" }).first()).toBeVisible();
await closeAll(ann);
});
test("the integrity panel renders the newest run", async ({ browser }) => {
const ann = await admin(browser);
// ask the worker for a run now; the nightly schedule has not come round in CI
const req = await call(ann, "post", "/api/jobs/integrity/run", { note: "e2e" });
if (req.status === 200) {
await expect
.poll(async () => Boolean((await api(ann, "get", "/api/admin/security/integrity")).run), { timeout: 90_000 })
.toBe(true);
}
const out = await api(ann, "get", "/api/admin/security/integrity");
expect(Array.isArray(out.findings)).toBe(true);
const page = ann.page;
await openAdminTab(page, "security");
const panel = page.locator("section").filter({ has: page.getByRole("heading", { name: "Integrity check" }) });
await expect(panel).toBeVisible();
if (out.run) {
await expect(panel.getByText(/^Finished /)).toBeVisible();
const findings = (out.findings as Json[]).length;
if (findings) {
await expect(panel.getByText(new RegExp(`^${findings} findings?$`))).toBeVisible();
await expect(panel.getByRole("table", { name: "Integrity findings" })).toBeVisible();
} else {
await expect(panel.getByText(/^(Clean|Failed)$/)).toBeVisible();
}
} else {
await expect(panel.getByText("The integrity check has not run yet.", { exact: false })).toBeVisible();
}
await closeAll(ann);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { expect, test } from "@playwright/test";
import { dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers";
import { makeDraft, makeProject, makeUnit, runSuffix, type Made } from "./pmhelpers";
import type { Json } from "./whhelpers";
import { admin, call, openAdminTab, sigOf, stampLines, tampered } from "./adminhelpers";
// Phase 9 Admin, Stamp check tab, on the CI stack. clerk.clara makes a bare draft and exports its job traveler, a
// controlled copy, as the close spec does; the print log records it with the stamp signature. The stamp line is read
// out of the PDF's own text. admin.ann pastes it in the Stamp check tab: Genuine, with who printed what. The same
// line with one character of the code changed does not check out. The PDF itself, uploaded, checks out too.
let RUN = "";
let P: Made;
let PDF: Buffer;
let LINE = "";
test.describe.serial("admin stamp check", () => {
test.setTimeout(120_000);
test("setup: clara exports a traveler; its stamp line is in the PDF", async ({ browser }, info) => {
RUN = runSuffix(info.project.name);
const clara = await actor(browser, "clerk.clara");
const proj = await makeProject(clara, `SV${RUN}`, { name: "Stamp check job" });
const unit = await makeUnit(clara, Number(proj.id), "001");
P = await makeDraft(clara, unit, `Stamp check ${RUN}`);
const r = await clara.page.request.get(`/api/packets/${P.id}/pdf/traveler`);
expect(r.status(), await r.text()).toBe(200);
expect(r.headers()["content-type"]).toContain("application/pdf");
PDF = await r.body();
const lines = stampLines(PDF);
expect(lines.length, "a signed stamp line in the traveler PDF").toBeGreaterThan(0);
LINE = lines[0];
expect(LINE).toContain(dcUser("clerk.clara").display_name);
const prints = ((await api(clara, "get", `/api/packets/${P.id}/prints`)).prints ?? []) as Json[];
expect(prints[0]?.kind).toBe("traveler");
await closeAll(clara);
});
test("the stamp line checks out; a tampered one does not", async ({ browser }) => {
const ann = await admin(browser);
// through the API
const ok = await api(ann, "post", "/api/admin/stamp-verify", { text: LINE });
expect(ok.found).toBe(true);
expect(ok.valid).toBe(true);
expect(ok.print.packet_id).toBe(P.id);
expect(ok.print.kind).toBe("traveler");
expect(ok.print.by_name).toBe(dcUser("clerk.clara").display_name);
const hexOnly = await api(ann, "post", "/api/admin/stamp-verify", { text: sigOf(LINE) });
expect(hexOnly.found).toBe(true);
const bad = await api(ann, "post", "/api/admin/stamp-verify", { text: tampered(LINE) });
expect(bad.valid).toBe(false);
const empty = await call(ann, "post", "/api/admin/stamp-verify", { text: "no stamp here" });
expect([200, 422]).toContain(empty.status);
if (empty.status === 200) expect(empty.body.valid).toBe(false);
// through the tab
const page = ann.page;
await openAdminTab(page, "stampverify");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a stamp line" }) });
const box = form.getByLabel("Stamp line or code", { exact: true });
await box.fill(LINE);
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
const result = form.getByRole("status", { name: "Stamp result" });
await expect(result).toContainText("Genuine.");
await expect(result).toContainText("Job traveler");
await expect(result).toContainText(dcUser("clerk.clara").display_name);
await box.fill(tampered(LINE));
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
await expect(result).toContainText(/Not found\.|Does not verify\./);
await expect(result).not.toContainText("Genuine.");
await closeAll(ann);
});
test("the exported PDF uploaded: its stamp checks out", async ({ browser }) => {
const ann = await admin(browser);
const r = await ann.page.request.post("/api/admin/stamp-verify/pdf", {
headers: { Origin: new URL(ann.page.url()).origin, "X-Requested-With": "PacketStation" },
multipart: { file: { name: `traveler-${P.id}.pdf`, mimeType: "application/pdf", buffer: PDF } },
});
expect(r.status(), await r.text()).toBe(200);
const stamps = ((await r.json()) as Json).stamps as Json[];
expect(stamps.length).toBeGreaterThan(0);
expect(stamps.some((s) => s.valid === true && s.print?.packet_id === P.id)).toBe(true);
const page = ann.page;
await openAdminTab(page, "stampverify");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a PDF" }) });
await form
.getByLabel("PDF of a printed copy", { exact: true })
.setInputFiles({ name: `traveler-${P.id}.pdf`, mimeType: "application/pdf", buffer: PDF });
await form.getByRole("button", { name: "Check PDF", exact: true }).click();
const results = form.getByRole("status", { name: "PDF stamp results" });
await expect(results).toContainText("Stamp 1: Genuine.");
await expect(results).toContainText("Job traveler");
await closeAll(ann);
});
});
+236
View File
@@ -0,0 +1,236 @@
import { inflateSync, constants as zc } from "node:zlib";
import { expect, type Browser, type Locator, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { actor, type Actor } from "./shophelpers";
import { getJson, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 9 Admin scenarios (Departments, Notifications, Security, Access log, Stamp check, Who is
// in): the Admin tabs by their ids, the admin's own user, API calls through the page's session, a CSV caught from a
// link or a download, and the stamp line read out of a PDF's content streams. The tab labels are in A (the phase 9
// design contract). The labels inside each tab are the texts of agent D's components (frontend/src/screens/admin/*)
// and sit in the specs next to their use.
export const A = {
tabs: {
departments: "Departments",
notifications: "Notifications",
security: "Security",
accesslog: "Access log",
stampverify: "Stamp check",
presence: "Who is in",
},
} as const;
/** The run suffix: five base-36 characters, upper case. */
export function runId(): string {
return Date.now().toString(36).toUpperCase().slice(-5);
}
/** admin.ann in a fresh browser context, signed in with her password. */
export function admin(browser: Browser): Promise<Actor> {
return actor(browser, "admin.ann");
}
/** The signed-in user (GET /api/auth/me). */
export async function me(a: Actor): Promise<Json> {
return (await getJson(a.page.request, "/api/auth/me")).user as Json;
}
/** An Admin tab button by its id (admtab-<id>), falling back to the visible label. */
export function adminTab(page: Page, id: keyof typeof A.tabs): Locator {
return page
.locator(`#admtab-${id}`)
.or(page.getByRole("tab", { name: A.tabs[id], exact: true }))
.first();
}
/** Open /admin on the tab and wait for it to be selected. Returns the tab panel. */
export async function openAdminTab(page: Page, id: keyof typeof A.tabs): Promise<Locator> {
await page.goto("/admin");
const t = adminTab(page, id);
await expect(t).toBeVisible();
await t.click();
await expect(t).toHaveAttribute("aria-selected", "true");
const panel = page.getByRole("tabpanel").first();
await expect(panel).toBeVisible();
return panel;
}
/** A raw API call that returns the status and the parsed body, whatever the status. */
export async function call(
a: Actor,
method: "get" | "post" | "patch" | "put" | "delete",
url: string,
data?: Json,
): Promise<{ status: number; body: Json }> {
const r = await a.page.request.fetch(url, {
method: method.toUpperCase(),
headers: writeHeaders(a.page),
...(data !== undefined ? { data } : {}),
});
const text = await r.text();
let body: Json = {};
try {
body = text ? (JSON.parse(text) as Json) : {};
} catch {
body = { text };
}
return { status: r.status(), body };
}
/** A failed password sign-in through the API from a page on /signin. Returns the status. */
export async function badSignin(page: Page, login: string): Promise<number> {
if (!page.url().includes("/signin")) await page.goto("/signin");
const r = await page.request.post("/api/auth/signin/password", {
headers: writeHeaders(page),
data: { login, password: `wrong-${Date.now()}` },
});
return r.status();
}
/**
* The CSV a control hands out: a link is fetched through the page's session; a button must start a download.
* Returns the text without a BOM.
*/
export async function csvFrom(page: Page, target: Locator): Promise<string> {
await expect(target).toBeVisible();
const href = await target.getAttribute("href");
let text: string;
if (href && !href.startsWith("blob:") && !href.startsWith("data:")) {
const r = await page.request.get(new URL(href, page.url()).toString());
expect(r.status(), `GET ${href}`).toBe(200);
expect(r.headers()["content-type"]).toContain("text/csv");
text = await r.text();
} else {
const [dl] = await Promise.all([page.waitForEvent("download"), target.click()]);
const path = await dl.path();
if (!path) throw new Error("download has no path");
text = readFileSync(path, "utf-8");
}
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
/** Split CSV text into rows of cells (RFC 4180 quoting). */
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (c === '"') quoted = false;
else cell += c;
} else if (c === '"') quoted = true;
else if (c === ",") {
row.push(cell);
cell = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else cell += c;
}
if (cell !== "" || row.length) {
row.push(cell);
rows.push(row);
}
return rows;
}
// ---------------------------------------------------------------- PDF text
function ascii85(data: string): Buffer {
let s = data.replace(/\s+/g, "");
if (s.startsWith("<~")) s = s.slice(2);
const end = s.indexOf("~>");
if (end >= 0) s = s.slice(0, end);
const out: number[] = [];
let group: number[] = [];
for (const ch of s) {
if (ch === "z" && group.length === 0) {
out.push(0, 0, 0, 0);
continue;
}
group.push(ch.charCodeAt(0) - 33);
if (group.length === 5) {
let v = 0;
for (const g of group) v = v * 85 + g;
out.push((v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255);
group = [];
}
}
if (group.length) {
const n = group.length;
while (group.length < 5) group.push(84);
let v = 0;
for (const g of group) v = v * 85 + g;
const bytes = [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
out.push(...bytes.slice(0, n - 1));
}
return Buffer.from(out);
}
/** Every content stream of a PDF decoded (ASCII85 and Flate), as latin1 text, plus the raw file. */
export function pdfStreams(pdf: Buffer): string[] {
const raw = pdf.toString("latin1");
const out: string[] = [raw];
const re = /stream\r?\n/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
if (raw.slice(Math.max(0, m.index - 3), m.index) === "end") continue;
const start = m.index + m[0].length;
const stop = raw.indexOf("endstream", start);
if (stop < 0) break;
const dict = raw.slice(Math.max(0, m.index - 400), m.index);
const dictStart = dict.lastIndexOf("obj");
const head = dictStart >= 0 ? dict.slice(dictStart) : dict;
let body: Buffer = Buffer.from(raw.slice(start, stop).replace(/\r?\n$/, ""), "latin1");
try {
if (head.includes("/ASCII85Decode") || head.includes("/A85")) body = ascii85(body.toString("latin1"));
if (head.includes("/FlateDecode") || head.includes("/Fl")) {
body = inflateSync(body, { finishFlush: zc.Z_SYNC_FLUSH });
}
out.push(body.toString("latin1"));
} catch {
// not a stream we can read (an image, a font); skip it
}
re.lastIndex = stop;
}
return out;
}
/** The signed stamp lines in a PDF ("... - stamp <16 hex>"), de-duplicated, PDF string escapes undone. */
export function stampLines(pdf: Buffer): string[] {
const found = new Set<string>();
for (const s of pdfStreams(pdf)) {
// PDF string escapes: octal (\055 is "-", \050 "("), then \( \) \\ and the rest as the character itself
const text = s.replace(/\\([0-7]{1,3}|[\s\S])/g, (_, e: string) =>
/^[0-7]+$/.test(e) ? String.fromCharCode(parseInt(e, 8)) : e,
);
for (const m of text.matchAll(/(?:Controlled copy|PRINTED COPY)[^\r\n]*? - stamp [0-9a-f]{16}/g)) {
found.add(m[0]);
}
}
return [...found];
}
/** The 16 hex of a stamp line. */
export function sigOf(line: string): string {
const m = /stamp ([0-9a-f]{16})\s*$/.exec(line);
if (!m) throw new Error(`no stamp signature in ${line}`);
return m[1];
}
/** The same line with the last hex character of the signature changed. */
export function tampered(line: string): string {
const sig = sigOf(line);
const last = sig.charAt(15);
const swapped = sig.slice(0, 15) + (last === "0" ? "1" : "0");
return line.replace(sig, swapped);
}
+5 -2
View File
@@ -122,9 +122,12 @@ export function runSuffix(projectName: string): string {
return projectName === "chromium" ? base : `${base}${projectName.slice(0, 1).toUpperCase()}`;
}
/** Today plus `days`, as YYYY-MM-DD (UTC calendar; the specs only need a day or more of margin). */
/** Today plus `days`, as YYYY-MM-DD in the site's calendar (America/Denver), as the server counts due dates.
* The UTC calendar is a day ahead every evening after 18:00 Denver time, which made "due yesterday" today. */
export function isoDay(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Denver" }).format(
new Date(Date.now() + days * 86_400_000),
);
}
export interface Unit {
+23 -5
View File
@@ -120,11 +120,29 @@ test.describe.serial("device, PIN and admin", () => {
);
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();
// Phase 9: the button asks the worker for an ad-reconcile run (202); the report follows.
expect(resp.status()).toBe(202);
const requestedAt = Date.now();
await expect
.poll(
async () => {
const r = await page.request.get("/api/admin/ad/reconcile");
if (!r.ok()) return null;
const body = (await r.json()) as {
run: { finished_at: string | null } | null;
report: { users_seen: number; emp_duplicate: string[] } | null;
};
const done = body.run?.finished_at ? Date.parse(body.run.finished_at) : 0;
return done >= requestedAt - 5000 ? body.report : null;
},
{ timeout: 90_000, intervals: [1000, 2000, 3000] },
)
.toMatchObject({ users_seen: expect.any(Number) });
const rep = (await (await page.request.get("/api/admin/ad/reconcile")).json()) as {
report: { users_seen: number; emp_duplicate: string[] };
};
expect(rep.report.users_seen).toBeGreaterThanOrEqual(15);
expect(rep.report.emp_duplicate).toContain("9001");
await tab(page, /devices/i).click();
await expect(page.getByText("CI tablet").first()).toBeVisible();