Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
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.
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
|
import { queryKeys } from "@/lib/queries";
|
|
import type { Settings, SettingsPatch } from "@/lib/types";
|
|
import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures";
|
|
|
|
/**
|
|
* System in database mode: the settings form, its diff contract, and the
|
|
* certificate reload that is a runtime action under both authorities.
|
|
*/
|
|
|
|
// What the server actually reports: the listener binds and `web.enabled`.
|
|
// Every other key applies live, so it carries no mark at all.
|
|
const RESTART_KEYS = RESTART_REQUIRED_KEYS;
|
|
|
|
let stored: Settings;
|
|
let putBodies: SettingsPatch[];
|
|
let putResponse: (() => Response | Promise<Response>) | null;
|
|
let restartPending: boolean;
|
|
|
|
function json(payload: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
/** The server's echo: the patch merged into the stored settings, password excepted. */
|
|
function applyPatch(patch: SettingsPatch): void {
|
|
const target = stored as unknown as Record<string, Record<string, unknown>>;
|
|
for (const [section, fields] of Object.entries(patch)) {
|
|
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
if (section === "web" && key === "password") continue;
|
|
target[section]![key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */
|
|
function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean {
|
|
return Object.entries(patch).some(([section, fields]) =>
|
|
Object.keys(fields as Record<string, unknown>).some((key) => keys.includes(`${section}.${key}`)),
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
stored = baseSettings();
|
|
putBodies = [];
|
|
putResponse = null;
|
|
restartPending = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) {
|
|
stubApi(DATABASE, {
|
|
responses: {
|
|
"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
|
|
"GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }),
|
|
},
|
|
onWrite: (call) => {
|
|
if (call.url !== "/api/settings") return null;
|
|
const patch = call.body as SettingsPatch;
|
|
putBodies.push(patch);
|
|
if (putResponse !== null) return putResponse();
|
|
applyPatch(patch);
|
|
if (needsRestart(patch, restartKeys)) restartPending = true;
|
|
return json({ settings: stored, restart_required: restartKeys });
|
|
},
|
|
});
|
|
const router = await renderPage("/configuration/system", "System");
|
|
await screen.findByRole("button", { name: "Save" });
|
|
return router;
|
|
}
|
|
|
|
function saveButton(): HTMLButtonElement {
|
|
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
|
}
|
|
|
|
function restartNotice(): HTMLElement | null {
|
|
return screen.queryByText(/Saved changes are not running yet/);
|
|
}
|
|
|
|
test("no changes means Save is disabled, and authentication reads as derived", async () => {
|
|
await openSystem();
|
|
|
|
expect(saveButton().disabled).toBe(true);
|
|
const auth = screen.getByText(/^Authentication: required/);
|
|
expect(auth.textContent).toContain("derived from whether a password is stored");
|
|
});
|
|
|
|
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
|
|
await openSystem();
|
|
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
|
expect(saveButton().disabled).toBe(false);
|
|
|
|
fireEvent.click(saveButton());
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
|
|
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
|
});
|
|
|
|
test("a restart-required key is marked as one, from the envelope's list", async () => {
|
|
await openSystem();
|
|
|
|
// The DNS binds and the port are the section's whole share of the list;
|
|
// `rate_limit` and `rate_window_seconds` apply live and carry no mark.
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
expect(within(dns).getAllByText("needs restart")).toHaveLength(3);
|
|
const cache = screen.getByRole("group", { name: "Cache" });
|
|
expect(within(cache).queryByText("needs restart")).toBeNull();
|
|
});
|
|
|
|
test("every key the server applies live is drawn without restart messaging", async () => {
|
|
await openSystem();
|
|
|
|
// Silence is the report for a live key: no mark on the field, and editing
|
|
// one owes nothing afterwards either.
|
|
for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) {
|
|
const section = screen.getByRole("group", { name: title });
|
|
expect(within(section).queryByText("needs restart")).toBeNull();
|
|
}
|
|
|
|
const logging = screen.getByRole("group", { name: "Logging" });
|
|
fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } });
|
|
fireEvent.click(saveButton());
|
|
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } });
|
|
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
|
expect(restartNotice()).toBeNull();
|
|
});
|
|
|
|
test("a port edit still owes a restart, and the shell says so", async () => {
|
|
await openSystem();
|
|
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
|
fireEvent.click(saveButton());
|
|
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
await screen.findByText(/Saved changes are not running yet/);
|
|
});
|
|
|
|
test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
|
|
// No shipped restart-required key is enum-backed, but the list is the
|
|
// server's to change, so the `Select` rendering is pinned against one.
|
|
await openSystem(["logging.level"]);
|
|
|
|
const logging = screen.getByRole("group", { name: "Logging" });
|
|
// `logging.level` is a Select and `logging.output` is not on the list, so
|
|
// exactly one mark belongs to this section.
|
|
expect(within(logging).getAllByText("needs restart")).toHaveLength(1);
|
|
const marker = within(logging).getByText("needs restart");
|
|
const marked = marker.parentElement!;
|
|
const trigger = within(marked).getByRole("button", { name: /level$/ });
|
|
// Visible next to the control is not enough: the marker sits outside the
|
|
// label, so only `aria-describedby` carries it to a screen reader.
|
|
expect(marker.id).not.toBe("");
|
|
expect(trigger.getAttribute("aria-describedby")?.split(" ")).toContain(marker.id);
|
|
});
|
|
|
|
test("enum and boolean fields diff as their own types", async () => {
|
|
await openSystem();
|
|
|
|
const logging = screen.getByRole("group", { name: "Logging" });
|
|
// A RAC Select names its trigger with the current value and then the label,
|
|
// and carries the options only while the listbox is open.
|
|
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
|
|
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
|
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
|
fireEvent.click(within(logging).getByLabelText("hide_domains"));
|
|
fireEvent.click(saveButton());
|
|
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
|
});
|
|
|
|
test("clearing a number field disables Save instead of sending NaN", async () => {
|
|
await openSystem();
|
|
|
|
const cache = screen.getByRole("group", { name: "Cache" });
|
|
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
|
expect(saveButton().disabled).toBe(true);
|
|
});
|
|
|
|
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
|
await openSystem();
|
|
|
|
const web = screen.getByRole("group", { name: "Web" });
|
|
const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
|
|
const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
|
|
expect(passwordInput.value).toBe("");
|
|
|
|
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
|
expect(screen.getByText(/signs out every session/)).toBeTruthy();
|
|
expect(screen.getByText("Passwords do not match.")).toBeTruthy();
|
|
expect(saveButton().disabled).toBe(true);
|
|
|
|
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
|
expect(screen.queryByText("Passwords do not match.")).toBeNull();
|
|
expect(saveButton().disabled).toBe(false);
|
|
|
|
fireEvent.click(saveButton());
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });
|
|
|
|
await waitFor(() => expect(passwordInput.value).toBe(""));
|
|
expect(confirmInput.value).toBe("");
|
|
// The password applies live, so the server never raises the flag.
|
|
expect(restartNotice()).toBeNull();
|
|
});
|
|
|
|
test("a patch of live keys alone leaves the server owing nothing", async () => {
|
|
await openSystem();
|
|
|
|
const web = screen.getByRole("group", { name: "Web" });
|
|
fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
|
|
fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
|
|
fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
|
|
fireEvent.click(saveButton());
|
|
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
|
|
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
|
expect(restartNotice()).toBeNull();
|
|
});
|
|
|
|
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
|
|
await openSystem();
|
|
let resolvePut!: (response: Response) => void;
|
|
putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));
|
|
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
const port = within(dns).getByLabelText(/^port/) as HTMLInputElement;
|
|
fireEvent.change(port, { target: { value: "5353" } });
|
|
fireEvent.click(saveButton());
|
|
|
|
await screen.findByRole("button", { name: "Saving…" });
|
|
expect(port.matches(":disabled")).toBe(true);
|
|
expect(screen.getByRole("group", { name: "Web" }).querySelector("#web\\.password")?.matches(":disabled")).toBe(
|
|
true,
|
|
);
|
|
|
|
applyPatch(putBodies[putBodies.length - 1]!);
|
|
resolvePut(json({ settings: stored, restart_required: RESTART_KEYS }));
|
|
await waitFor(() => expect(port.matches(":disabled")).toBe(false));
|
|
expect(saveButton().textContent).toBe("Save");
|
|
});
|
|
|
|
test("a 429 shows the rate-limit countdown from Retry-After", async () => {
|
|
await openSystem();
|
|
putResponse = () =>
|
|
new Response(JSON.stringify({ error: "too many requests" }), {
|
|
status: 429,
|
|
headers: { "content-type": "application/json", "Retry-After": "30" },
|
|
});
|
|
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
|
fireEvent.click(saveButton());
|
|
|
|
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s.");
|
|
expect(restartNotice()).toBeNull();
|
|
});
|
|
|
|
test("a 400 validation error surfaces inline and owes no restart", async () => {
|
|
await openSystem();
|
|
putResponse = () => json({ error: "dns.port out of range" }, 400);
|
|
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "70000" } });
|
|
fireEvent.click(saveButton());
|
|
|
|
expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
|
|
expect(restartNotice()).toBeNull();
|
|
expect(saveButton().disabled).toBe(false);
|
|
});
|
|
|
|
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
|
|
const router = await openSystem();
|
|
const queryClient = router.options.context.queryClient;
|
|
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
|
|
|
// Someone else changes cache.size; a background refetch brings it in. The
|
|
// derived authentication line is read straight from the query data, so it
|
|
// witnesses that the refetch reached the component.
|
|
stored.cache.size = 99999;
|
|
stored.web.auth_enabled = false;
|
|
await act(async () => {
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
|
|
});
|
|
await waitFor(() => expect(screen.getByText(/^Authentication: not configured/)).toBeTruthy());
|
|
const cache = screen.getByRole("group", { name: "Cache" });
|
|
expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");
|
|
|
|
fireEvent.click(saveButton());
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
|
|
});
|
|
|
|
test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
|
|
await openSystem();
|
|
const dns = screen.getByRole("group", { name: "DNS" });
|
|
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
|
|
fireEvent.click(saveButton());
|
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
|
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
|
|
|
fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5454" } });
|
|
fireEvent.click(saveButton());
|
|
await waitFor(() => expect(putBodies).toHaveLength(2));
|
|
expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
|
|
});
|
|
|
|
test("Reload certificates reports each endpoint's outcome in database mode (D8)", async () => {
|
|
stubApi(DATABASE, {
|
|
responses: {
|
|
"POST /api/certs/reload": {
|
|
doh: { enabled: true, reloaded: true, error: null },
|
|
dot: { enabled: false, reloaded: false, error: null },
|
|
},
|
|
},
|
|
});
|
|
await renderPage("/configuration/system", "System");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
|
|
const result = await screen.findByText(/DoH: reloaded/);
|
|
expect(result.textContent).toContain("DoT: not enabled");
|
|
});
|
|
|
|
test("Reload certificates works under file authority too, and states a failure (D8)", async () => {
|
|
stubApi(MANAGED_FILE, {
|
|
responses: {
|
|
"POST /api/certs/reload": {
|
|
doh: { enabled: true, reloaded: false, error: "cert.pem: no such file" },
|
|
dot: { enabled: true, reloaded: true, error: null },
|
|
},
|
|
},
|
|
});
|
|
await renderPage("/configuration/system", "System");
|
|
|
|
const button = screen.getByRole("button", { name: "Reload certificates" }) as HTMLButtonElement;
|
|
expect(button.disabled).toBe(false);
|
|
fireEvent.click(button);
|
|
|
|
const result = await screen.findByText(/DoH: failed/);
|
|
expect(result.textContent).toContain("cert.pem: no such file");
|
|
expect(result.textContent).toContain("DoT: reloaded");
|
|
});
|
|
|
|
test("a failed certificate reload request is an error, not an outcome", async () => {
|
|
stubApi(DATABASE, {
|
|
onWrite: (call) => (call.url === "/api/certs/reload" ? json({ error: "reload is busy" }, 409) : null),
|
|
});
|
|
await renderPage("/configuration/system", "System");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
|
|
expect((await screen.findByRole("alert")).textContent).toBe("reload is busy");
|
|
});
|