import { render, screen, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import type { Authority, Settings, SettingsEnvelope } from "@/lib/types"; // One file for the whole file-mode sweep: the settings envelope is the only // discovery mechanism, so every page test needs the same stubbed envelope. const CONFIG_PATH = "/etc/nxdns/config.zon"; const DATABASE: Authority = { mode: "database", path: null, reconciled_at: null }; const MANAGED_FILE: Authority = { mode: "managed_file", path: CONFIG_PATH, reconciled_at: 1754899200 }; 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 }, }; } function envelope(authority: Authority): SettingsEnvelope { return { settings: baseSettings(), restart_required: [], authority }; } const GROUPS = { groups: [ { id: 1, name: "default", safe_search: false }, { id: 2, name: "kids", safe_search: true }, ], }; const BLOCKLISTS = { blocklists: [ { id: 1, url: "https://example.com/ads.txt", name: "Ads", enabled: true, is_suggested: false, last_updated: null, domain_count: 100, wildcard_count: 0, skipped_regex_count: 0, checksum: null, }, ], }; const RULES = { rules: [ { id: 1, group_id: 1, group: "default", pattern: "ads.example.com", kind: "exact", action: "block", created_at: 1700000000, }, ], }; const CLIENTS = { clients: [ { id: 1, ip: "192.168.1.10", name: "laptop", group_id: 1, group: "default", hand_edited: true, first_seen: 1700000000, last_seen: 1700003600, }, { id: 2, ip: "192.168.1.11", name: "", group_id: 2, group: "kids", hand_edited: false, first_seen: 1700000000, last_seen: 1700007200, }, ], }; const PREFIXES = { client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }], }; const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; const BASE: Record = { "GET /api/version": VERSION, "GET /api/groups": GROUPS, "GET /api/blocklists": BLOCKLISTS, "GET /api/rules": RULES, "GET /api/clients": CLIENTS, "GET /api/client-prefixes": PREFIXES, }; function stubFetch(map: Record) { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const key = `${init?.method ?? "GET"} ${String(input)}`; const payload = map[key]; if (payload === undefined) { return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 }); } return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, }); }), ); } async function renderAt(route: string, heading: string, authority: Authority) { stubFetch({ ...BASE, "GET /api/settings": envelope(authority) }); const queryClient = createQueryClient(); const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient); render( , ); await screen.findByRole("heading", { name: heading }); } function button(name: string): HTMLButtonElement { return screen.getByRole("button", { name }) as HTMLButtonElement; } function clientRow(ip: string): HTMLElement { const row = screen.getByText(ip).closest("tr"); if (row === null) throw new Error(`no client row for ${ip}`); return row; } afterEach(() => { vi.unstubAllGlobals(); }); test("the banner names the managed file in file mode", async () => { await renderAt("/rules", "Rules", MANAGED_FILE); const banner = await screen.findByText(/configuration is managed by/i); expect(banner.textContent).toContain(CONFIG_PATH); expect(banner.textContent).toMatch(/restart/i); expect(banner.closest('[role="status"]')).toBeTruthy(); }); test("the banner is absent in database mode", async () => { await renderAt("/rules", "Rules", DATABASE); await screen.findByRole("button", { name: "Create rule" }); expect(screen.queryByText(/configuration is managed by/i)).toBeNull(); }); function kidsRow(): HTMLElement { const row = screen.getByText("kids").closest("li"); if (row === null) throw new Error("no row for group kids"); return row; } test("file mode disables the Groups create and delete controls", async () => { await renderAt("/groups", "Groups", MANAGED_FILE); await screen.findByText(/configuration is managed by/i); expect(button("Create").disabled).toBe(true); expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true); expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(true); }); test("database mode leaves the Groups create and delete controls enabled", async () => { await renderAt("/groups", "Groups", DATABASE); await screen.findByRole("button", { name: "Create" }); expect(button("Create").disabled).toBe(false); expect((within(kidsRow()).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); expect((within(kidsRow()).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).disabled).toBe(false); }); test("file mode disables the Rules create and delete controls", async () => { await renderAt("/rules", "Rules", MANAGED_FILE); await screen.findByText(/configuration is managed by/i); expect(button("Create rule").disabled).toBe(true); expect(button("Delete").disabled).toBe(true); }); test("database mode leaves the Rules create and delete controls enabled", async () => { await renderAt("/rules", "Rules", DATABASE); await screen.findByRole("button", { name: "Create rule" }); expect(button("Create rule").disabled).toBe(false); expect(button("Delete").disabled).toBe(false); }); test("file mode keeps delete live for an observed client and blocks it for a declared one", async () => { await renderAt("/clients", "Clients", MANAGED_FILE); await screen.findByText(/configuration is managed by/i); const declared = clientRow("192.168.1.10"); const observed = clientRow("192.168.1.11"); expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true); expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); expect((within(observed).getByRole("button", { name: "Edit" }) as HTMLButtonElement).disabled).toBe(true); }); test("file mode leaves the blocklist refresh button enabled", async () => { await renderAt("/blocklists", "Blocklists", MANAGED_FILE); await screen.findByText(/configuration is managed by/i); expect(button("Update now").disabled).toBe(false); expect(button("Add source").disabled).toBe(true); expect((screen.getByRole("checkbox", { name: "Ads enabled" }) as HTMLInputElement).disabled).toBe(true); });