import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures"; /** * Protection in database mode: the group-centred master/detail, the rules * scoped to the selected group, and the shared source catalogue with its one * runtime action. */ let calls: Call[]; afterEach(() => { vi.unstubAllGlobals(); }); async function openProtection(group?: number) { calls = stubApi(DATABASE); const suffix = group === undefined ? "" : `?group=${group}`; return renderPage(`/configuration/protection${suffix}`, "Protection"); } function writes(method: string): Call[] { return calls.filter((call) => call.method === method); } test("the group list is the master, and the selected group is the detail", async () => { await openProtection(); const list = within(screen.getByRole("navigation", { name: "Groups" })); expect(list.getByRole("link", { name: "default" })).toBeTruthy(); expect(list.getByRole("link", { name: "kids" })).toBeTruthy(); await screen.findByRole("heading", { name: "default", level: 2 }); }); test("the default group cannot be renamed or deleted, and says why", async () => { await openProtection(1); await screen.findByRole("heading", { name: "default", level: 2 }); expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole("button", { name: "Delete group" }) as HTMLButtonElement).disabled).toBe(true); expect(screen.getByText("The default group cannot be renamed or deleted.")).toBeTruthy(); }); test("another group can be renamed and deleted, and carries its safe-search state", async () => { await openProtection(2); await screen.findByRole("heading", { name: "kids", level: 2 }); expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false); expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true); fireEvent.click(screen.getByRole("button", { name: "Delete group" })); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain('Delete group "kids"?'); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(writes("DELETE")).toEqual([]); }); test("toggling safe search resends the whole group row", async () => { await openProtection(2); await screen.findByRole("heading", { name: "kids", level: 2 }); fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" })); await waitFor(() => expect(writes("PUT")).toHaveLength(1)); expect(writes("PUT")[0]).toMatchObject({ url: "/api/groups/2", body: { name: "kids", safe_search: false }, }); }); test("the source assignment saves the full set via PUT", async () => { await openProtection(2); await screen.findByRole("heading", { name: "kids", level: 2 }); const ads = (await screen.findByRole("checkbox", { name: "Ads" })) as HTMLInputElement; expect(ads.checked).toBe(false); const save = screen.getByRole("button", { name: "Save sources" }) as HTMLButtonElement; expect(save.disabled).toBe(true); fireEvent.click(ads); expect(save.disabled).toBe(false); fireEvent.click(save); await waitFor(() => expect(writes("PUT")).toHaveLength(1)); expect(writes("PUT")[0]).toMatchObject({ url: "/api/groups/2/sources", body: { source_ids: [1] }, }); }); test("only the selected group's rules are listed", async () => { await openProtection(2); await screen.findByRole("heading", { name: "kids", level: 2 }); expect(await screen.findByText("*.social.example")).toBeTruthy(); expect(screen.queryByText("ads.example.com")).toBeNull(); }); test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => { await openProtection(2); await screen.findByRole("heading", { name: "Create rule in kids", level: 4 }); // An exact pattern is trimmed; a regex is stored and matched byte for byte. fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } }); fireEvent.click(screen.getByRole("button", { name: "Create rule" })); await waitFor(() => expect(writes("POST")).toHaveLength(1)); expect(writes("POST")[0]?.body).toEqual({ group_id: 2, pattern: "ads.example.net", kind: "exact", action: "block", }); }); test("a rate-limited rule create shows the countdown from Retry-After", async () => { calls = stubApi(DATABASE, { onWrite: (call) => call.url === "/api/rules" ? new Response(JSON.stringify({ error: "rate limited" }), { status: 429, headers: { "content-type": "application/json", "Retry-After": "12" }, }) : null, }); await renderPage("/configuration/protection?group=2", "Protection"); await screen.findByRole("heading", { name: "Create rule in kids", level: 4 }); fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } }); fireEvent.click(screen.getByRole("button", { name: "Create rule" })); expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 12s."); }); test("cancelling the rule delete confirmation leaves the rule alone", async () => { await openProtection(2); await screen.findByText("*.social.example"); fireEvent.click(screen.getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog"); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(writes("DELETE")).toEqual([]); expect(screen.getByText("*.social.example")).toBeTruthy(); }); test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => { await openProtection(2); const pattern = await screen.findByLabelText("Pattern"); expect(pattern.getAttribute("autocapitalize")).toBe("none"); expect(pattern.getAttribute("autocorrect")).toBe("off"); expect(pattern.getAttribute("spellcheck")).toBe("false"); }); test("the kind selector offers the three contract kinds and can pick regex", async () => { await openProtection(2); await screen.findByLabelText("Pattern"); fireEvent.click(screen.getByRole("button", { name: /Kind$/ })); const options = await screen.findAllByRole("option"); expect(options.map((option) => option.textContent)).toEqual(["exact", "wildcard", "regex"]); fireEvent.click(screen.getByRole("option", { name: "regex" })); await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ^ad[0-9]+- " } }); fireEvent.click(screen.getByRole("button", { name: "Create rule" })); await waitFor(() => expect(writes("POST")).toHaveLength(1)); expect(writes("POST")[0]?.body).toMatchObject({ pattern: " ^ad[0-9]+- ", kind: "regex" }); }); test("deleting a rule asks first, then issues the DELETE", async () => { await openProtection(2); await screen.findByText("*.social.example"); fireEvent.click(screen.getByRole("button", { name: "Delete" })); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain('Delete the block rule for "*.social.example"?'); fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); await waitFor(() => expect(writes("DELETE")).toHaveLength(1)); expect(writes("DELETE")[0]?.url).toBe("/api/rules/2"); }); test("the client count links into Clients filtered by the group (D3)", async () => { await openProtection(2); const link = await screen.findByRole("link", { name: "1 client in this group" }); expect(link.getAttribute("href")).toBe("/clients?group=2"); }); test("a group can be created from the master column", async () => { await openProtection(); fireEvent.change(await screen.findByLabelText("New group"), { target: { value: " guests " } }); fireEvent.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => expect(writes("POST")).toHaveLength(1)); expect(writes("POST")[0]).toMatchObject({ url: "/api/groups", body: { name: "guests" } }); }); test("the Sources tab lists the catalogue with both skipped columns and their note", async () => { calls = stubApi(DATABASE); await renderPage("/configuration/protection?tab=sources", "Protection"); expect(await screen.findByText("Ads")).toBeTruthy(); expect(screen.getByText("Trackers")).toBeTruthy(); expect(screen.getByText("Suggested")).toBeTruthy(); expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy(); expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy(); expect( screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/), ).toBeTruthy(); expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true); expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false); expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy(); }); test("Update now says it started, and says nothing once it succeeds", async () => { let release: ((response: Response) => void) | null = null; calls = stubApi(DATABASE, { onWrite: (call) => // Held open so the started state is observable, not a frame that resolves // before the assertion. call.url === "/api/blocklists/update" ? new Promise((resolve) => { release = resolve; }) : null, }); await renderPage("/configuration/protection?tab=sources", "Protection"); fireEvent.click(await screen.findByRole("button", { name: "Update now" })); const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement; expect(pending.disabled).toBe(true); expect(screen.getByRole("status").textContent).toBe("Update started…"); release!( new Response(JSON.stringify({ sources: [] }), { status: 202, headers: { "content-type": "application/json" }, }), ); await waitFor(() => expect(screen.getByRole("button", { name: "Update now" })).toBeTruthy()); // Success leaves no standing claim behind: the refreshed counters are the // signal, and a "counters refreshed" line would outlive a failed refetch. expect(screen.queryByRole("status")).toBeNull(); }); test("a rate-limited Update now shows the countdown from Retry-After", async () => { calls = stubApi(DATABASE, { onWrite: (call) => call.url === "/api/blocklists/update" ? new Response(JSON.stringify({ error: "rate limited" }), { status: 429, headers: { "content-type": "application/json", "Retry-After": "7" }, }) : null, }); await renderPage("/configuration/protection?tab=sources", "Protection"); fireEvent.click(await screen.findByRole("button", { name: "Update now" })); const alert = await screen.findByRole("alert"); expect(alert.textContent).toBe("Rate limited. Try again in 7s."); }); test("deleting a source asks first, then issues the DELETE for that source", async () => { calls = stubApi(DATABASE); await renderPage("/configuration/protection?tab=sources", "Protection"); await screen.findByText("Ads"); fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain('Delete blocklist "Trackers"? Its domains stop being blocked.'); fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); await waitFor(() => expect(writes("DELETE")).toHaveLength(1)); expect(writes("DELETE")[0]?.url).toBe("/api/blocklists/2"); }); test("cancelling the source delete confirmation leaves the source alone", async () => { calls = stubApi(DATABASE); await renderPage("/configuration/protection?tab=sources", "Protection"); await screen.findByText("Ads"); fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!); const dialog = await screen.findByRole("alertdialog"); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(writes("DELETE")).toEqual([]); expect(screen.getByText("Trackers")).toBeTruthy(); });