278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
import { Suspense } from "react";
|
|
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { act } from "react";
|
|
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
|
|
import RestartBanner from "@/features/settings/RestartBanner";
|
|
import { dismissRestartBanner } from "@/features/settings/restartBanner";
|
|
import { createQueryClient } from "@/lib/queryClient";
|
|
import { queryKeys } from "@/lib/queries";
|
|
import type { Settings, SettingsPatch } from "@/lib/types";
|
|
|
|
function baseSettings(): Settings {
|
|
return {
|
|
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
|
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
|
blocking: { response: "zero", ttl: 300 },
|
|
cache: { size: 10000, negative_ttl_max: 300 },
|
|
web: {
|
|
enabled: true,
|
|
bind: "127.0.0.1",
|
|
port: 8080,
|
|
session_ttl_hours: 24,
|
|
api_rate_limit_per_min: 60,
|
|
api_localhost_exempt: true,
|
|
sse_max_connections_per_ip: 2,
|
|
trusted_proxies: "",
|
|
auth_enabled: true,
|
|
},
|
|
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
|
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
|
edns: { ecs_mode: "strip" },
|
|
logging: {
|
|
level: "info",
|
|
retention_days: 30,
|
|
query_log_buffer_max: 10000,
|
|
hide_domains: false,
|
|
hide_client_ips: false,
|
|
output: "stderr",
|
|
file_path: "",
|
|
max_size_mb: 50,
|
|
max_files: 3,
|
|
},
|
|
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
|
blocklist_update: { enabled: true, interval_hours: 24 },
|
|
};
|
|
}
|
|
|
|
let putBodies: SettingsPatch[];
|
|
let putResponse: () => Response | Promise<Response>;
|
|
let storedSettings: Settings;
|
|
|
|
function jsonResponse(payload: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
function applyPatch(patch: SettingsPatch): void {
|
|
const settings = storedSettings 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;
|
|
settings[section]![key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
act(() => dismissRestartBanner());
|
|
putBodies = [];
|
|
storedSettings = baseSettings();
|
|
putResponse = () => {
|
|
applyPatch(putBodies[putBodies.length - 1]!);
|
|
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
|
|
};
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url !== "/api/settings") return jsonResponse({ error: "not stubbed" }, 404);
|
|
if (init?.method === "PUT") {
|
|
putBodies.push(JSON.parse(String(init.body)) as SettingsPatch);
|
|
return putResponse();
|
|
}
|
|
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
async function renderPage(): Promise<QueryClient> {
|
|
const queryClient = createQueryClient();
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<RestartBanner />
|
|
<Suspense fallback={<p>loading</p>}>
|
|
<SettingsPage />
|
|
</Suspense>
|
|
</QueryClientProvider>,
|
|
);
|
|
await screen.findByRole("heading", { name: "Settings" });
|
|
return queryClient;
|
|
}
|
|
|
|
function saveButton(): HTMLButtonElement {
|
|
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
|
}
|
|
|
|
test("no changes means Save is disabled and auth_enabled shows read-only", async () => {
|
|
await renderPage();
|
|
expect(saveButton().disabled).toBe(true);
|
|
expect(screen.getByText(/auth_enabled: true/).textContent).toContain("read-only");
|
|
});
|
|
|
|
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
|
|
await renderPage();
|
|
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 } });
|
|
|
|
expect((await screen.findByRole("status")).textContent).toContain("Restart nxdns to apply");
|
|
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
|
});
|
|
|
|
test("enum and boolean fields diff as their own types", async () => {
|
|
await renderPage();
|
|
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 renderPage();
|
|
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 banner", async () => {
|
|
await renderPage();
|
|
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("");
|
|
expect(screen.queryByRole("status")).toBeNull();
|
|
});
|
|
|
|
test("a mixed patch with a password still raises the banner", async () => {
|
|
await renderPage();
|
|
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" } });
|
|
expect(await screen.findByRole("status")).toBeTruthy();
|
|
});
|
|
|
|
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
|
|
await renderPage();
|
|
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);
|
|
const web = screen.getByRole("group", { name: "Web" });
|
|
expect(within(web).getByLabelText("password").matches(":disabled")).toBe(true);
|
|
|
|
applyPatch(putBodies[putBodies.length - 1]!);
|
|
resolvePut(jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] }));
|
|
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 renderPage();
|
|
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(screen.queryByRole("status")).toBeNull();
|
|
});
|
|
|
|
test("a 400 validation error surfaces inline and raises no banner", async () => {
|
|
await renderPage();
|
|
putResponse = () => jsonResponse({ 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(screen.queryByRole("status")).toBeNull();
|
|
expect(saveButton().disabled).toBe(false);
|
|
});
|
|
|
|
test("patchRequiresRestart ignores only a bare web.password", () => {
|
|
expect(patchRequiresRestart({ web: { password: "x" } })).toBe(false);
|
|
expect(patchRequiresRestart({ web: { password: "x", port: 9090 } })).toBe(true);
|
|
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
|
|
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
|
|
});
|
|
|
|
test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
|
|
const queryClient = await renderPage();
|
|
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 auth_enabled line is read straight from the query data, so it
|
|
// witnesses that the refetch reached the component.
|
|
storedSettings.cache.size = 99999;
|
|
storedSettings.web.auth_enabled = false;
|
|
await act(async () => {
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
|
|
});
|
|
await waitFor(() => expect(screen.getByText(/auth_enabled: false/)).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 renderPage();
|
|
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 } });
|
|
});
|