import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { queryKeys } from "@/lib/queries"; import type { Settings, SettingsPatch } from "@/lib/types"; import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures"; /** * System in database mode: the settings form, its diff contract, and the * certificate reload that is a runtime action under both authorities. */ // What the server actually reports: the listener binds and `web.enabled`. // Every other key applies live, so it carries no mark at all. const RESTART_KEYS = RESTART_REQUIRED_KEYS; let stored: Settings; let putBodies: SettingsPatch[]; let putResponse: (() => Response | Promise) | null; let restartPending: boolean; function json(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } }); } /** The server's echo: the patch merged into the stored settings, password excepted. */ function applyPatch(patch: SettingsPatch): void { const target = stored as unknown as Record>; for (const [section, fields] of Object.entries(patch)) { for (const [key, value] of Object.entries(fields as Record)) { if (section === "web" && key === "password") continue; target[section]![key] = value; } } } /** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */ function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean { return Object.entries(patch).some(([section, fields]) => Object.keys(fields as Record).some((key) => keys.includes(`${section}.${key}`)), ); } beforeEach(() => { stored = baseSettings(); putBodies = []; putResponse = null; restartPending = false; }); afterEach(() => { vi.unstubAllGlobals(); }); async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) { stubApi(DATABASE, { responses: { "GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }), "GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }), }, onWrite: (call) => { if (call.url !== "/api/settings") return null; const patch = call.body as SettingsPatch; putBodies.push(patch); if (putResponse !== null) return putResponse(); applyPatch(patch); if (needsRestart(patch, restartKeys)) restartPending = true; return json({ settings: stored, restart_required: restartKeys }); }, }); const router = await renderPage("/configuration/system", "System"); await screen.findByRole("button", { name: "Save" }); return router; } function saveButton(): HTMLButtonElement { return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; } function restartNotice(): HTMLElement | null { return screen.queryByText(/Saved changes are not running yet/); } test("no changes means Save is disabled, and authentication reads as derived", async () => { await openSystem(); expect(saveButton().disabled).toBe(true); const auth = screen.getByText(/^Authentication: required/); expect(auth.textContent).toContain("derived from whether a password is stored"); }); test("a changed field enables Save and the PUT body is exactly the diff", async () => { await openSystem(); const dns = screen.getByRole("group", { name: "DNS" }); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } }); expect(saveButton().disabled).toBe(false); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); expect(putBodies[0]).toEqual({ dns: { port: 5353 } }); await waitFor(() => expect(saveButton().disabled).toBe(true)); }); test("a restart-required key is marked as one, from the envelope's list", async () => { await openSystem(); // The DNS binds and the port are the section's whole share of the list; // `rate_limit` and `rate_window_seconds` apply live and carry no mark. const dns = screen.getByRole("group", { name: "DNS" }); expect(within(dns).getAllByText("needs restart")).toHaveLength(3); const cache = screen.getByRole("group", { name: "Cache" }); expect(within(cache).queryByText("needs restart")).toBeNull(); }); test("every key the server applies live is drawn without restart messaging", async () => { await openSystem(); // Silence is the report for a live key: no mark on the field, and editing // one owes nothing afterwards either. for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) { const section = screen.getByRole("group", { name: title }); expect(within(section).queryByText("needs restart")).toBeNull(); } const logging = screen.getByRole("group", { name: "Logging" }); fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } }); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } }); await waitFor(() => expect(saveButton().disabled).toBe(true)); expect(restartNotice()).toBeNull(); }); test("a port edit still owes a restart, and the shell says so", async () => { await openSystem(); const dns = screen.getByRole("group", { name: "DNS" }); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } }); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); await screen.findByText(/Saved changes are not running yet/); }); test("an enum-backed key on the list is marked too, not only text and number fields", async () => { // No shipped restart-required key is enum-backed, but the list is the // server's to change, so the `Select` rendering is pinned against one. await openSystem(["logging.level"]); const logging = screen.getByRole("group", { name: "Logging" }); // `logging.level` is a Select and `logging.output` is not on the list, so // exactly one mark belongs to this section. expect(within(logging).getAllByText("needs restart")).toHaveLength(1); const marker = within(logging).getByText("needs restart"); const marked = marker.parentElement!; const trigger = within(marked).getByRole("button", { name: /level$/ }); // Visible next to the control is not enough: the marker sits outside the // label, so only `aria-describedby` carries it to a screen reader. expect(marker.id).not.toBe(""); expect(trigger.getAttribute("aria-describedby")?.split(" ")).toContain(marker.id); }); test("enum and boolean fields diff as their own types", async () => { await openSystem(); const logging = screen.getByRole("group", { name: "Logging" }); // A RAC Select names its trigger with the current value and then the label, // and carries the options only while the listbox is open. fireEvent.click(within(logging).getByRole("button", { name: /level$/ })); fireEvent.click(await screen.findByRole("option", { name: "debug" })); await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); fireEvent.click(within(logging).getByLabelText("hide_domains")); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } }); }); test("clearing a number field disables Save instead of sending NaN", async () => { await openSystem(); const cache = screen.getByRole("group", { name: "Cache" }); fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } }); expect(saveButton().disabled).toBe(true); }); test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => { await openSystem(); const web = screen.getByRole("group", { name: "Web" }); const passwordInput = within(web).getByLabelText("password") as HTMLInputElement; const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement; expect(passwordInput.value).toBe(""); fireEvent.change(passwordInput, { target: { value: "hunter2" } }); expect(screen.getByText(/signs out every session/)).toBeTruthy(); expect(screen.getByText("Passwords do not match.")).toBeTruthy(); expect(saveButton().disabled).toBe(true); fireEvent.change(confirmInput, { target: { value: "hunter2" } }); expect(screen.queryByText("Passwords do not match.")).toBeNull(); expect(saveButton().disabled).toBe(false); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); expect(putBodies[0]).toEqual({ web: { password: "hunter2" } }); await waitFor(() => expect(passwordInput.value).toBe("")); expect(confirmInput.value).toBe(""); // The password applies live, so the server never raises the flag. expect(restartNotice()).toBeNull(); }); test("a patch of live keys alone leaves the server owing nothing", async () => { await openSystem(); const web = screen.getByRole("group", { name: "Web" }); fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } }); fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } }); fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } }); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } }); await waitFor(() => expect(saveButton().disabled).toBe(true)); expect(restartNotice()).toBeNull(); }); test("the form is disabled while the PUT is pending and re-enabled after success", async () => { await openSystem(); let resolvePut!: (response: Response) => void; putResponse = () => new Promise((resolve) => (resolvePut = resolve)); const dns = screen.getByRole("group", { name: "DNS" }); const port = within(dns).getByLabelText(/^port/) as HTMLInputElement; fireEvent.change(port, { target: { value: "5353" } }); fireEvent.click(saveButton()); await screen.findByRole("button", { name: "Saving…" }); expect(port.matches(":disabled")).toBe(true); expect(screen.getByRole("group", { name: "Web" }).querySelector("#web\\.password")?.matches(":disabled")).toBe( true, ); applyPatch(putBodies[putBodies.length - 1]!); resolvePut(json({ settings: stored, restart_required: RESTART_KEYS })); await waitFor(() => expect(port.matches(":disabled")).toBe(false)); expect(saveButton().textContent).toBe("Save"); }); test("a 429 shows the rate-limit countdown from Retry-After", async () => { await openSystem(); putResponse = () => new Response(JSON.stringify({ error: "too many requests" }), { status: 429, headers: { "content-type": "application/json", "Retry-After": "30" }, }); const dns = screen.getByRole("group", { name: "DNS" }); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } }); fireEvent.click(saveButton()); expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s."); expect(restartNotice()).toBeNull(); }); test("a 400 validation error surfaces inline and owes no restart", async () => { await openSystem(); putResponse = () => json({ error: "dns.port out of range" }, 400); const dns = screen.getByRole("group", { name: "DNS" }); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "70000" } }); fireEvent.click(saveButton()); expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range"); expect(restartNotice()).toBeNull(); expect(saveButton().disabled).toBe(false); }); test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => { const router = await openSystem(); const queryClient = router.options.context.queryClient; const dns = screen.getByRole("group", { name: "DNS" }); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } }); // Someone else changes cache.size; a background refetch brings it in. The // derived authentication line is read straight from the query data, so it // witnesses that the refetch reached the component. stored.cache.size = 99999; stored.web.auth_enabled = false; await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.settings }); }); await waitFor(() => expect(screen.getByText(/^Authentication: not configured/)).toBeTruthy()); const cache = screen.getByRole("group", { name: "Cache" }); expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000"); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); expect(putBodies[0]).toEqual({ dns: { port: 5353 } }); }); test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => { await openSystem(); const dns = screen.getByRole("group", { name: "DNS" }); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } }); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); await waitFor(() => expect(saveButton().disabled).toBe(true)); fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5454" } }); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(2)); expect(putBodies[1]).toEqual({ dns: { port: 5454 } }); }); test("Reload certificates reports each endpoint's outcome in database mode (D8)", async () => { stubApi(DATABASE, { responses: { "POST /api/certs/reload": { doh: { enabled: true, reloaded: true, error: null }, dot: { enabled: false, reloaded: false, error: null }, }, }, }); await renderPage("/configuration/system", "System"); fireEvent.click(screen.getByRole("button", { name: "Reload certificates" })); const result = await screen.findByText(/DoH: reloaded/); expect(result.textContent).toContain("DoT: not enabled"); }); test("Reload certificates works under file authority too, and states a failure (D8)", async () => { stubApi(MANAGED_FILE, { responses: { "POST /api/certs/reload": { doh: { enabled: true, reloaded: false, error: "cert.pem: no such file" }, dot: { enabled: true, reloaded: true, error: null }, }, }, }); await renderPage("/configuration/system", "System"); const button = screen.getByRole("button", { name: "Reload certificates" }) as HTMLButtonElement; expect(button.disabled).toBe(false); fireEvent.click(button); const result = await screen.findByText(/DoH: failed/); expect(result.textContent).toContain("cert.pem: no such file"); expect(result.textContent).toContain("DoT: reloaded"); }); test("a failed certificate reload request is an error, not an outcome", async () => { stubApi(DATABASE, { onWrite: (call) => (call.url === "/api/certs/reload" ? json({ error: "reload is busy" }, 409) : null), }); await renderPage("/configuration/system", "System"); fireEvent.click(screen.getByRole("button", { name: "Reload certificates" })); expect((await screen.findByRole("alert")).textContent).toBe("reload is busy"); });