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