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
223 lines
8.7 KiB
TypeScript
223 lines
8.7 KiB
TypeScript
import { screen, waitFor, within } from "@testing-library/react";
|
|
import { CONFIG_PATH, DATABASE, MANAGED_FILE, contentArea, renderPage, renderRoute, stubApi } from "./testFixtures";
|
|
|
|
/**
|
|
* Authority is three-state, and the difference between the states is the whole
|
|
* point: an unresolved status is never treated as database mode, and file mode
|
|
* is a different rendering rather than the same forms with their controls
|
|
* turned off.
|
|
*/
|
|
|
|
const NEVER = new Promise<Response>(() => {});
|
|
|
|
/**
|
|
* Every button the file-mode configuration content is allowed to contain: the
|
|
* runtime actions, and the Retry an error surface offers. Anything else is a
|
|
* mutation control that should not have been rendered at all.
|
|
*/
|
|
const RUNTIME_BUTTONS = ["Update now", "Reload certificates", "Retry"];
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function mutationControls(): Element[] {
|
|
return [
|
|
...contentArea().querySelectorAll(
|
|
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
|
|
),
|
|
];
|
|
}
|
|
|
|
function buttonLabels(): string[] {
|
|
return [...contentArea().querySelectorAll("button")].map((button) => (button.textContent ?? "").trim());
|
|
}
|
|
|
|
test("a pending status renders neither form nor definition list on Protection", async () => {
|
|
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
|
|
await renderPage("/configuration/protection", "Protection");
|
|
|
|
expect(await screen.findByText(/which configuration source this server obeys/i)).toBeTruthy();
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(screen.queryByRole("button", { name: "Create" })).toBeNull();
|
|
});
|
|
|
|
test("a pending status still leaves the runtime actions live (R3-4)", async () => {
|
|
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
|
|
await renderPage("/configuration/system", "System");
|
|
|
|
const reload = (await screen.findByRole("button", { name: "Reload certificates" })) as HTMLButtonElement;
|
|
expect(reload.disabled).toBe(false);
|
|
// The settings form is not drawn behind it.
|
|
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
|
|
});
|
|
|
|
test("a pending status renders neither form nor definition list on Resolution", async () => {
|
|
stubApi(DATABASE, { responses: { "GET /api/config/status": NEVER } });
|
|
await renderPage("/configuration/resolution", "Resolution");
|
|
|
|
expect(await screen.findByText(/which configuration source this server obeys/i)).toBeTruthy();
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(screen.queryByRole("button", { name: "Add upstream" })).toBeNull();
|
|
});
|
|
|
|
test("a failed status renders the error and a Retry, never editable forms", async () => {
|
|
stubApi(DATABASE, {
|
|
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
|
|
});
|
|
await renderPage("/configuration/protection", "Protection");
|
|
|
|
await screen.findByText(/cannot say whether a file or the database owns/i);
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
|
|
expect(mutationControls()).toHaveLength(0);
|
|
});
|
|
|
|
test("a failed status renders the error and a Retry on Resolution too", async () => {
|
|
stubApi(DATABASE, {
|
|
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
|
|
});
|
|
await renderPage("/configuration/resolution", "Resolution");
|
|
|
|
await screen.findByText(/cannot say whether a file or the database owns/i);
|
|
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(screen.queryByRole("button", { name: "Add upstream" })).toBeNull();
|
|
});
|
|
|
|
test("a failed status keeps Update now enabled on the Sources tab", async () => {
|
|
stubApi(DATABASE, {
|
|
responses: { "GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }) },
|
|
});
|
|
await renderPage("/configuration/protection?tab=sources", "Protection");
|
|
|
|
await screen.findByText(/cannot say whether a file or the database owns/i);
|
|
const update = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
|
|
expect(update.disabled).toBe(false);
|
|
});
|
|
|
|
test("a failed status is announced by the shell on a page that is not configuration", async () => {
|
|
stubApi(DATABASE, {
|
|
responses: {
|
|
"GET /api/config/status": new Response(JSON.stringify({ error: "gone" }), { status: 404 }),
|
|
"GET /api/stats?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
queries: 0,
|
|
blocked: 0,
|
|
clients: 0,
|
|
avg_response_time_us: null,
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
},
|
|
});
|
|
renderRoute("/overview");
|
|
|
|
await waitFor(() => expect(screen.getByText(/file authority and pending restarts cannot be shown/i)).toBeTruthy());
|
|
});
|
|
|
|
test("database authority renders the editable groups form", async () => {
|
|
stubApi(DATABASE);
|
|
await renderPage("/configuration/protection", "Protection");
|
|
|
|
expect(await screen.findByRole("button", { name: "Create" })).toBeTruthy();
|
|
expect(screen.getByLabelText("New group")).toBeTruthy();
|
|
expect(screen.queryByText(/loaded from/i)).toBeNull();
|
|
});
|
|
|
|
test("file authority renders definitions with their exact ZON keys, not a form", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/protection", "Protection");
|
|
|
|
const note = await screen.findByText(/are loaded from/i);
|
|
expect(note.textContent).toContain(CONFIG_PATH);
|
|
|
|
const detail = await screen.findByText("Safe search");
|
|
expect(within(detail).getByText("groups[].safe_search")).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: "Create" })).toBeNull();
|
|
});
|
|
|
|
test("file mode: Protection renders zero mutation controls (D12)", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/protection", "Protection");
|
|
await screen.findByText("groups[].safe_search");
|
|
|
|
expect(mutationControls()).toHaveLength(0);
|
|
for (const label of buttonLabels()) expect(RUNTIME_BUTTONS).toContain(label);
|
|
});
|
|
|
|
test("file mode: the Sources tab shows the catalogue with its key and only Update now", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/protection?tab=sources", "Protection");
|
|
await screen.findByText("blocklist_sources");
|
|
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(buttonLabels()).toEqual(["Update now"]);
|
|
});
|
|
|
|
test("file mode: the catalogue keeps provenance and both skipped counters", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/protection?tab=sources", "Protection");
|
|
await screen.findByText("blocklist_sources");
|
|
|
|
// Trackers is the suggested source, and the one with skipped lines: hiding
|
|
// either fact under file authority would hide a parse failure.
|
|
const row = screen.getByText("Trackers").closest("tr")!;
|
|
expect(within(row).getByText("Suggested")).toBeTruthy();
|
|
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
|
|
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
|
|
const cells = [...row.querySelectorAll("td")].map((cell) => cell.textContent);
|
|
expect(cells).toContain("3");
|
|
expect(cells).toContain("4");
|
|
expect(
|
|
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test("file mode: the Resolution upstream pool is a table with no controls", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/resolution", "Resolution");
|
|
await screen.findByText("upstreams");
|
|
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(buttonLabels()).toEqual([]);
|
|
});
|
|
|
|
test("file mode: the Records tab is a table with no controls", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/resolution?tab=records", "Resolution");
|
|
await screen.findByText("local_records");
|
|
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(buttonLabels()).toEqual([]);
|
|
});
|
|
|
|
test("file mode: the Forward zones tab is a table with no controls", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/resolution?tab=zones", "Resolution");
|
|
await screen.findByText("forward_zones");
|
|
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(buttonLabels()).toEqual([]);
|
|
});
|
|
|
|
test("file mode: System renders scalars as definitions and keeps Reload certificates", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/system", "System");
|
|
|
|
const retention = await screen.findByText("Retention days");
|
|
expect(within(retention).getByText("logging.retention_days")).toBeTruthy();
|
|
expect(mutationControls()).toHaveLength(0);
|
|
expect(buttonLabels()).toEqual(["Reload certificates"]);
|
|
});
|
|
|
|
test("file mode: authentication is a derived status with no invented key (D13)", async () => {
|
|
stubApi(MANAGED_FILE);
|
|
await renderPage("/configuration/system", "System");
|
|
|
|
const auth = await screen.findByText("Authentication");
|
|
expect(auth.textContent).toBe("Authentication");
|
|
expect(within(auth).queryByText(/web\.auth_enabled/)).toBeNull();
|
|
expect(screen.queryByText("web.auth_enabled")).toBeNull();
|
|
});
|