Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
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<Response>((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();
|
|
});
|