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]>
255 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|