Files
PacketStation/tests/e2e/tests/admin-notifications.spec.ts
T
spencermandClaude Opus 5.5 a82e056b6a
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
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]>
2026-10-21 18:27:09 -06:00

255 lines
12 KiB
TypeScript

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);
});
});