Phase 9: Admin completion, reconcile, notifications, integrity, backups
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user