settings and upstream writes now follow a prepare, commit, publish, retire contract: candidates are built and validated before the database transaction, published as infallible pointer swaps, and old generations retire after their readers drain. per-query policy values snapshot once per query; upstream pool, cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and the query-log queue each gained one named live operation. restart_required shrinks from every scalar key to the bind keys and web.enabled; the admin ui drops its restart notices for everything else. file mode is unchanged.
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
|
|
|
|
/**
|
|
* Resolution in database mode: the upstream pool, the local records and the
|
|
* forward zones, each rehomed from its own page onto a tab of one.
|
|
*/
|
|
|
|
let calls: Call[];
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function writes(): Call[] {
|
|
return calls;
|
|
}
|
|
|
|
async function openResolution(tab?: string, options: Parameters<typeof stubApi>[1] = {}) {
|
|
calls = stubApi(DATABASE, options);
|
|
const suffix = tab === undefined ? "" : `?tab=${tab}`;
|
|
return renderPage(`/configuration/resolution${suffix}`, "Resolution");
|
|
}
|
|
|
|
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
|
|
async function openDeleteDialog(index = 0) {
|
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
|
|
return await screen.findByRole("alertdialog");
|
|
}
|
|
|
|
test("the upstream pool is the default tab and lists every field", async () => {
|
|
await openResolution();
|
|
|
|
expect(await screen.findByText("udp://1.1.1.1:53")).toBeTruthy();
|
|
expect(screen.getByText("tls://9.9.9.9:853")).toBeTruthy();
|
|
expect(screen.getByText("dns.quad9.net")).toBeTruthy();
|
|
expect((screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement).checked).toBe(true);
|
|
expect((screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement).checked).toBe(false);
|
|
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
|
|
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
|
|
});
|
|
|
|
test("adding an upstream posts every field", async () => {
|
|
await openResolution();
|
|
await screen.findByRole("heading", { name: "Add upstream" });
|
|
|
|
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
|
|
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "150" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
|
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toEqual({
|
|
url: "/api/upstreams",
|
|
method: "POST",
|
|
body: { url: "udp://8.8.8.8:53", priority: 150, enabled: true, tls_name: "" },
|
|
});
|
|
});
|
|
|
|
test("an upstream write applies live, so the tab says nothing about a restart", async () => {
|
|
// The server rebuilds the pool on the write and echoes `restart_required:
|
|
// false`, so `restart_pending` stays down and silence is the whole report.
|
|
await openResolution(undefined, { responses: { "GET /api/config/status": () => DATABASE } });
|
|
await screen.findByRole("heading", { name: "Add upstream" });
|
|
|
|
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
|
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(screen.queryByText(/Restart nxdns to apply them/)).toBeNull();
|
|
expect(contentArea().textContent).not.toMatch(/restart/i);
|
|
});
|
|
|
|
test("toggling enabled resends the whole row", async () => {
|
|
await openResolution();
|
|
await screen.findByLabelText("tls://9.9.9.9:853 enabled");
|
|
|
|
fireEvent.click(screen.getByLabelText("tls://9.9.9.9:853 enabled"));
|
|
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toEqual({
|
|
url: "/api/upstreams/2",
|
|
method: "PUT",
|
|
body: { url: "tls://9.9.9.9:853", priority: 200, enabled: true, tls_name: "dns.quad9.net" },
|
|
});
|
|
});
|
|
|
|
test("upstream delete asks for confirmation and skips the request when cancelled", async () => {
|
|
await openResolution();
|
|
await screen.findByText("udp://1.1.1.1:53");
|
|
|
|
const dialog = await openDeleteDialog();
|
|
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
|
|
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
|
expect(writes()).toHaveLength(0);
|
|
});
|
|
|
|
test("confirming the upstream delete dialog issues the DELETE", async () => {
|
|
await openResolution();
|
|
await screen.findByText("udp://1.1.1.1:53");
|
|
|
|
const dialog = await openDeleteDialog();
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
|
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/upstreams/1" });
|
|
});
|
|
|
|
test("a 409 on create renders the conflict text inline", async () => {
|
|
await openResolution(undefined, {
|
|
onWrite: () =>
|
|
new Response(JSON.stringify({ error: "an upstream with that url already exists" }), {
|
|
status: 409,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
});
|
|
await screen.findByRole("heading", { name: "Add upstream" });
|
|
|
|
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://1.1.1.1:53" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe("an upstream with that url already exists");
|
|
});
|
|
|
|
test("a 409 on toggle renders the last-enabled conflict", async () => {
|
|
await openResolution(undefined, {
|
|
onWrite: () =>
|
|
new Response(JSON.stringify({ error: "the last enabled upstream cannot be disabled" }), {
|
|
status: 409,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
});
|
|
await screen.findByLabelText("udp://1.1.1.1:53 enabled");
|
|
|
|
fireEvent.click(screen.getByLabelText("udp://1.1.1.1:53 enabled"));
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe("the last enabled upstream cannot be disabled");
|
|
});
|
|
|
|
test("a 409 on delete renders the last-enabled conflict", async () => {
|
|
await openResolution(undefined, {
|
|
onWrite: () =>
|
|
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
|
|
status: 409,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
});
|
|
await screen.findByText("udp://1.1.1.1:53");
|
|
|
|
const dialog = await openDeleteDialog();
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
|
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
|
|
});
|
|
|
|
test("editing a row seeds the form and PUTs the replaced row", async () => {
|
|
await openResolution();
|
|
await screen.findByText("tls://9.9.9.9:853");
|
|
|
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]!);
|
|
await screen.findByRole("heading", { name: "Edit tls://9.9.9.9:853" });
|
|
|
|
expect((screen.getByLabelText("URL") as HTMLInputElement).value).toBe("tls://9.9.9.9:853");
|
|
expect((screen.getByLabelText("Priority") as HTMLInputElement).value).toBe("200");
|
|
expect((screen.getByLabelText("TLS name") as HTMLInputElement).value).toBe("dns.quad9.net");
|
|
|
|
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "10" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
|
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toEqual({
|
|
url: "/api/upstreams/2",
|
|
method: "PUT",
|
|
body: { url: "tls://9.9.9.9:853", priority: 10, enabled: false, tls_name: "dns.quad9.net" },
|
|
});
|
|
await screen.findByRole("heading", { name: "Add upstream" });
|
|
});
|
|
|
|
test("the arrow keys move between tabs, and the panel follows", async () => {
|
|
await openResolution();
|
|
await screen.findByText("udp://1.1.1.1:53");
|
|
|
|
const tablist = screen.getByRole("tablist", { name: "Resolution" });
|
|
expect(screen.getByRole("tab", { name: "Upstreams" }).getAttribute("aria-selected")).toBe("true");
|
|
|
|
fireEvent.keyDown(tablist, { key: "ArrowRight" });
|
|
await waitFor(() =>
|
|
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true"),
|
|
);
|
|
await screen.findByText("nas.lan.home");
|
|
|
|
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
|
|
await waitFor(() =>
|
|
expect(screen.getByRole("tab", { name: "Upstreams" }).getAttribute("aria-selected")).toBe("true"),
|
|
);
|
|
await screen.findByText("udp://1.1.1.1:53");
|
|
});
|
|
|
|
test("creating a local record posts exactly the LocalRecordInput", async () => {
|
|
await openResolution("records");
|
|
await screen.findByText("nas.lan.home");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
|
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
|
|
// The record type is a RAC Select: open the listbox, then pick.
|
|
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
|
|
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
|
|
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toMatchObject({
|
|
url: "/api/local-records",
|
|
method: "POST",
|
|
body: { name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" },
|
|
});
|
|
});
|
|
|
|
test("the record delete dialog names the record and only deletes on confirm", async () => {
|
|
await openResolution("records");
|
|
await screen.findByText("nas.lan.home");
|
|
|
|
let dialog = await openDeleteDialog();
|
|
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
|
expect(writes()).toHaveLength(0);
|
|
|
|
dialog = await openDeleteDialog();
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/local-records/1" });
|
|
});
|
|
|
|
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
|
|
await openResolution("zones");
|
|
await screen.findByText("lan.home");
|
|
|
|
let dialog = await openDeleteDialog();
|
|
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
|
expect(writes()).toHaveLength(0);
|
|
|
|
dialog = await openDeleteDialog();
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
|
await waitFor(() => expect(writes()).toHaveLength(1));
|
|
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/forward-zones/1" });
|
|
});
|