import { Suspense } from "react"; import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act } from "react"; import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage"; import RestartBanner from "@/features/settings/RestartBanner"; import { dismissRestartBanner } from "@/features/settings/restartBanner"; import { createQueryClient } from "@/lib/queryClient"; import { queryKeys } from "@/lib/queries"; import type { Settings, SettingsPatch } from "@/lib/types"; function baseSettings(): Settings { return { upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 }, dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 }, blocking: { response: "zero", ttl: 300 }, cache: { size: 10000, negative_ttl_max: 300 }, web: { enabled: true, bind: "127.0.0.1", port: 8080, session_ttl_hours: 24, api_rate_limit_per_min: 60, api_localhost_exempt: true, sse_max_connections_per_ip: 2, trusted_proxies: "", auth_enabled: true, }, doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" }, dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" }, edns: { ecs_mode: "strip" }, logging: { level: "info", retention_days: 30, query_log_buffer_max: 10000, hide_domains: false, hide_client_ips: false, output: "stderr", file_path: "", max_size_mb: 50, max_files: 3, }, disk: { min_free_mb: 100, warn_free_mb: 500 }, blocklist_update: { enabled: true, interval_hours: 24 }, }; } let putBodies: SettingsPatch[]; let putResponse: () => Response | Promise; let storedSettings: Settings; function jsonResponse(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } }); } function applyPatch(patch: SettingsPatch): void { const settings = storedSettings 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; settings[section]![key] = value; } } } beforeEach(() => { act(() => dismissRestartBanner()); putBodies = []; storedSettings = baseSettings(); putResponse = () => { applyPatch(putBodies[putBodies.length - 1]!); return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] }); }; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url !== "/api/settings") return jsonResponse({ error: "not stubbed" }, 404); if (init?.method === "PUT") { putBodies.push(JSON.parse(String(init.body)) as SettingsPatch); return putResponse(); } return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] }); }), ); }); afterEach(() => { vi.unstubAllGlobals(); }); async function renderPage(): Promise { const queryClient = createQueryClient(); render( loading

}>
, ); await screen.findByRole("heading", { name: "Settings" }); return queryClient; } function saveButton(): HTMLButtonElement { return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; } test("no changes means Save is disabled and auth_enabled shows read-only", async () => { await renderPage(); expect(saveButton().disabled).toBe(true); expect(screen.getByText(/auth_enabled: true/).textContent).toContain("read-only"); }); test("a changed field enables Save and the PUT body is exactly the diff", async () => { await renderPage(); 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 } }); expect((await screen.findByRole("status")).textContent).toContain("Restart nxdns to apply"); await waitFor(() => expect(saveButton().disabled).toBe(true)); }); test("enum and boolean fields diff as their own types", async () => { await renderPage(); 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 renderPage(); 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 banner", async () => { await renderPage(); 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(""); expect(screen.queryByRole("status")).toBeNull(); }); test("a mixed patch with a password still raises the banner", async () => { await renderPage(); 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" } }); expect(await screen.findByRole("status")).toBeTruthy(); }); test("the form is disabled while the PUT is pending and re-enabled after success", async () => { await renderPage(); 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); const web = screen.getByRole("group", { name: "Web" }); expect(within(web).getByLabelText("password").matches(":disabled")).toBe(true); applyPatch(putBodies[putBodies.length - 1]!); resolvePut(jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] })); 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 renderPage(); 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(screen.queryByRole("status")).toBeNull(); }); test("a 400 validation error surfaces inline and raises no banner", async () => { await renderPage(); putResponse = () => jsonResponse({ 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(screen.queryByRole("status")).toBeNull(); expect(saveButton().disabled).toBe(false); }); test("patchRequiresRestart ignores only a bare web.password", () => { expect(patchRequiresRestart({ web: { password: "x" } })).toBe(false); expect(patchRequiresRestart({ web: { password: "x", port: 9090 } })).toBe(true); expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true); expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true); }); test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => { const queryClient = await renderPage(); 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 auth_enabled line is read straight from the query data, so it // witnesses that the refetch reached the component. storedSettings.cache.size = 99999; storedSettings.web.auth_enabled = false; await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.settings }); }); await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).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 renderPage(); 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 } }); });