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]>
99 lines
5.2 KiB
TypeScript
99 lines
5.2 KiB
TypeScript
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
import { describe, expect, it } from "vitest";
|
|
import { adminBase } from "../../test/admin";
|
|
import { renderApp } from "../../test/auth";
|
|
import { callsTo, mockFetch } from "../../test/fetchMock";
|
|
|
|
const SETTINGS = {
|
|
values: { session_idle_minutes: 15, signin_account_limit: 5, signin_burst_threshold: 20 },
|
|
defaults: { session_idle_minutes: 15, signin_account_limit: 5, signin_burst_threshold: 20 },
|
|
bounds: { session_idle_minutes: [1, 60], signin_account_limit: [1, 15], signin_burst_threshold: [5, 200] },
|
|
version: 2,
|
|
};
|
|
|
|
const ALERTS = [
|
|
{ at: "2026-10-21T09:00:00Z", kind: "lockout", user_id: "u-1", display_name: "Walt Welder", account: "weld.walt", ip: "10.0.0.9", detail: { minutes: 10 } },
|
|
{ at: "2026-10-21T08:00:00Z", kind: "admin_change", user_id: "u-0", display_name: "Ann Admin", account: "admin.ann", ip: "10.0.0.2", detail: { action: "admin.profile_updated" } },
|
|
];
|
|
|
|
const INTEGRITY = {
|
|
run: { id: 9, started_at: "2026-10-21T04:00:00Z", finished_at: "2026-10-21T04:02:00Z", status: "ok", counts: { seals: 120, assets: 40 } },
|
|
findings: [{ kind: "asset_hash", subject: "asset 77", detail: { expected: "ab", got: "cd" }, at: "2026-10-21T04:01:00Z" }],
|
|
};
|
|
|
|
function routes(extra: Parameters<typeof mockFetch>[0] = {}) {
|
|
return {
|
|
...adminBase(),
|
|
"GET /api/admin/security/settings": { body: SETTINGS },
|
|
"/api/admin/security/alerts": { body: ALERTS },
|
|
"/api/admin/security/integrity": { body: INTEGRITY },
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
async function openTab() {
|
|
renderApp("/admin");
|
|
fireEvent.click(await screen.findByRole("tab", { name: "Security" }));
|
|
return screen.findByLabelText("Lock after idle, minutes");
|
|
}
|
|
|
|
describe("Admin Security tab", () => {
|
|
it("shows each setting with its default and bounds, checks bounds, and saves only changes", async () => {
|
|
const fetch = mockFetch(
|
|
routes({
|
|
"PUT /api/admin/security/settings": { body: { ...SETTINGS, values: { ...SETTINGS.values, signin_account_limit: 4 }, version: 3 } },
|
|
}),
|
|
);
|
|
const idle = await openTab();
|
|
expect(idle).toHaveValue(15);
|
|
expect(idle).toHaveAccessibleDescription("Default 15. Allowed 1 to 60.");
|
|
const limit = screen.getByLabelText("Wrong passwords per account before a cooldown");
|
|
fireEvent.change(limit, { target: { value: "20" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("from 1 to 15");
|
|
expect(callsTo(fetch, "PUT", "/api/admin/security/settings")).toHaveLength(0);
|
|
|
|
fireEvent.change(limit, { target: { value: "4" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
|
|
expect(await screen.findByText(/Security settings saved/)).toBeInTheDocument();
|
|
expect(callsTo(fetch, "PUT", "/api/admin/security/settings")).toEqual([{ values: { signin_account_limit: 4 }, version: 2 }]);
|
|
});
|
|
|
|
it("reloads after a 409", async () => {
|
|
const fetch = mockFetch(routes({ "PUT /api/admin/security/settings": { status: 409, body: { detail: "Changed", code: "version_conflict" } } }));
|
|
await openTab();
|
|
fireEvent.change(screen.getByLabelText("Lock after idle, minutes"), { target: { value: "20" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
|
|
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/security/settings")).toHaveLength(2));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the settings first.");
|
|
});
|
|
|
|
it("lists alerts with kind filters and a period, and shows the integrity result", async () => {
|
|
const fetch = mockFetch(routes());
|
|
await openTab();
|
|
const table = await screen.findByRole("table", { name: "Security alerts" });
|
|
expect(within(table).getByText("Account cooldowns")).toBeInTheDocument();
|
|
expect(within(table).getByText("Admin changes")).toBeInTheDocument();
|
|
const kinds = screen.getByRole("group", { name: "Alert kinds" });
|
|
fireEvent.click(within(kinds).getByRole("button", { name: /Admin changes/ }));
|
|
expect(within(kinds).getByRole("button", { name: /Admin changes/ })).toHaveAttribute("aria-pressed", "false");
|
|
expect(within(table).queryByText("Admin changes")).not.toBeInTheDocument();
|
|
expect(within(table).getByText("Walt Welder")).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByRole("combobox", { name: /Period/ }), { target: { value: "168" } });
|
|
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/admin/security/alerts?hours=168")).toBe(true));
|
|
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/admin/security/alerts?hours=24")).toBe(true);
|
|
|
|
const findings = await screen.findByRole("table", { name: "Integrity findings" });
|
|
expect(within(findings).getByText("asset 77")).toBeInTheDocument();
|
|
expect(screen.getByText("1 finding")).toHaveClass("st-bad");
|
|
expect(screen.getByText("seals: 120, assets: 40")).toBeInTheDocument();
|
|
});
|
|
|
|
it("says when the integrity check has not run", async () => {
|
|
mockFetch(routes({ "/api/admin/security/integrity": { body: { run: null, findings: [] } } }));
|
|
await openTab();
|
|
expect(await screen.findByText(/has not run yet/)).toBeInTheDocument();
|
|
});
|
|
});
|