flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded. The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
384 lines
16 KiB
TypeScript
384 lines
16 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
|
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
|
|
import { createQueryClient } from "@/lib/queryClient";
|
|
import { createAppRouter } from "@/routes";
|
|
import { formatClock, formatTime } from "@/lib/format";
|
|
import { health } from "@/lib/healthFixture";
|
|
import type { ConfigStatus, Health } from "@/lib/types";
|
|
|
|
const NAV_LABELS = ["Overview", "Activity", "Clients", "Diagnostics"];
|
|
const SYSTEM_LABELS = ["Protection", "Resolution", "System", "Diagnostics"];
|
|
/** The pages the redesign folded into the three configuration ones. */
|
|
const GONE_LABELS = ["Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", "Settings"];
|
|
|
|
const CONFIG_PATH = "/etc/nxdns/config.zon";
|
|
const RECONCILED_AT = 1754899200;
|
|
|
|
const DATABASE: ConfigStatus = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
|
|
|
|
const RESPONSES: Record<string, unknown> = {
|
|
"/api/overview?period=24h": {
|
|
period: "24h",
|
|
since: 0,
|
|
until: 86400,
|
|
bucket_seconds: 1800,
|
|
totals: { queries: 0, blocked: 0, clients: 0, avg_response_time_us: null },
|
|
buckets: [],
|
|
clients: [],
|
|
other: [],
|
|
types: [],
|
|
routes: [],
|
|
coverage: { complete: true, available_since: 0 },
|
|
},
|
|
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
|
|
"/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
|
|
};
|
|
|
|
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */
|
|
let healthBody: Health | null;
|
|
/** Null makes the config status poll fail, which the shell has to say out loud. */
|
|
let configStatus: ConfigStatus | null;
|
|
|
|
function stubFetch(extra: (url: string) => Response | null = () => null) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const override = extra(url);
|
|
if (override !== null) return override;
|
|
if (url === "/api/config/status") {
|
|
const failed = configStatus === null;
|
|
return new Response(JSON.stringify(failed ? { error: "config status unavailable" } : configStatus), {
|
|
status: failed ? 503 : 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
if (url === "/api/health") {
|
|
const failed = healthBody === null;
|
|
return new Response(JSON.stringify(failed ? { error: "health unavailable" } : healthBody), {
|
|
status: failed ? 503 : 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
const payload = RESPONSES[url];
|
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
function renderShell() {
|
|
const queryClient = createQueryClient();
|
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
|
render(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
return router;
|
|
}
|
|
|
|
function diagnosticsBadgeText(): string | null {
|
|
const link = screen.getAllByRole("link", { name: /^Diagnostics/ })[0];
|
|
const badge = link.querySelector("[aria-label]");
|
|
return badge === null ? null : (badge.textContent ?? "");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sessionStorage.clear();
|
|
resetAuthProbeForTests();
|
|
healthBody = health();
|
|
configStatus = DATABASE;
|
|
stubFetch();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test("shell renders the overview route with all nav links", async () => {
|
|
renderShell();
|
|
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
|
expect(nav).toBeTruthy();
|
|
for (const label of [...NAV_LABELS, ...SYSTEM_LABELS]) {
|
|
expect(screen.getByRole("link", { name: label })).toBeTruthy();
|
|
}
|
|
for (const label of GONE_LABELS) {
|
|
expect(screen.queryByRole("link", { name: label })).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("main carries the ids the router scrolls and restores", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
const main = screen.getByRole("main");
|
|
expect(main.id).toBe("main-content");
|
|
expect(main.getAttribute("data-scroll-restoration-id")).toBe("main");
|
|
});
|
|
|
|
test("the configuration pages and Diagnostics sit under the System group, after Monitoring", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
const group = screen.getByRole("list", { name: "System" });
|
|
expect(
|
|
within(group)
|
|
.getAllByRole("link")
|
|
.map((link) => link.textContent),
|
|
).toEqual(SYSTEM_LABELS);
|
|
// The group is a section of Main, not a nav of its own.
|
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
|
expect(nav.contains(group)).toBe(true);
|
|
const clients = within(nav).getByRole("link", { name: "Clients" });
|
|
expect(clients.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
|
|
test("under file authority the nav states the file and when it was loaded", async () => {
|
|
configStatus = {
|
|
authority: "managed_file",
|
|
path: CONFIG_PATH,
|
|
reconciled_at: RECONCILED_AT,
|
|
restart_pending: false,
|
|
};
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
const line = await screen.findByText(/^File-managed ·/);
|
|
expect(line.textContent).toBe(`File-managed · ${CONFIG_PATH} · loaded ${formatTime(RECONCILED_AT)}`);
|
|
const group = screen.getByRole("list", { name: "System" });
|
|
expect(group.parentElement?.contains(line)).toBe(true);
|
|
});
|
|
|
|
test("under database authority there is no authority line to read", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
await screen.findByRole("list", { name: "System" });
|
|
|
|
expect(screen.queryByText(/File-managed/)).toBeNull();
|
|
});
|
|
|
|
test("a pending restart is announced on every page, with no way to dismiss it", async () => {
|
|
configStatus = { ...DATABASE, restart_pending: true };
|
|
renderShell();
|
|
|
|
const notice = await screen.findByText(/Saved changes are not running yet\. Restart nxdns to apply them\./);
|
|
expect(within(notice).queryByRole("button")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
|
|
});
|
|
|
|
test("the restart notice is server state, so a browser refresh does not clear it", async () => {
|
|
configStatus = { ...DATABASE, restart_pending: true };
|
|
renderShell();
|
|
await screen.findByText(/Saved changes are not running yet/);
|
|
|
|
// A refresh: everything client-side is thrown away and rebuilt from the API.
|
|
cleanup();
|
|
renderShell();
|
|
|
|
await screen.findByText(/Saved changes are not running yet/);
|
|
});
|
|
|
|
test("a failed config status is stated rather than passed off as database authority", async () => {
|
|
configStatus = null;
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
await screen.findByText(/Configuration status unavailable — file authority and pending restarts cannot be shown\./);
|
|
});
|
|
|
|
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
|
|
stubFetch((url) => {
|
|
if (url === "/api/auth/login")
|
|
return new Response(JSON.stringify({ error: "password required" }), {
|
|
status: 401,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
if (url === "/api/auth/logout")
|
|
return new Response(JSON.stringify({ error: "rate limited" }), {
|
|
status: 429,
|
|
headers: { "content-type": "application/json", "retry-after": "7" },
|
|
});
|
|
return null;
|
|
});
|
|
|
|
renderShell();
|
|
|
|
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
|
|
// and the header one below it.
|
|
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
|
|
const aside = document.querySelector("aside") as HTMLElement;
|
|
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
|
|
|
|
await within(aside).findByText("Rate limited. Try again in 7s.");
|
|
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
|
});
|
|
|
|
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
|
|
stubFetch((url) =>
|
|
url === "/api/auth/login"
|
|
? new Response(JSON.stringify({ error: "password required" }), {
|
|
status: 401,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
: null,
|
|
);
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
const aside = document.querySelector("aside") as HTMLElement;
|
|
const header = document.querySelector("header") as HTMLElement;
|
|
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
|
|
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
|
|
// The header is the narrow rendering now: the Menu button is its only nav.
|
|
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
|
|
|
|
// In the sidebar the control precedes the version label rather than crowding it.
|
|
const version = within(aside).getByText(/^nxdns v/);
|
|
const logout = within(aside).getByRole("button", { name: "Log out" });
|
|
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
// The drawer keeps no third copy: the header already carries the narrow one.
|
|
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
|
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
|
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
|
|
});
|
|
|
|
test("the header carries the protection state as a reading, never as a control", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
const header = within(document.querySelector("header") as HTMLElement);
|
|
await waitFor(() => expect(header.getByText("Protection active")).toBeTruthy());
|
|
expect(header.queryByRole("link", { name: /^Protection/ })).toBeNull();
|
|
expect(header.queryByRole("button", { name: /^Pause/ })).toBeNull();
|
|
});
|
|
|
|
test("the sidebar's foot states protection and uptime, and queries/min only on Overview", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
const aside = document.querySelector("aside") as HTMLElement;
|
|
const status = within(aside).getByLabelText("Status");
|
|
await waitFor(() => expect(within(status).getByText("Active")).toBeTruthy());
|
|
// 1 second of uptime from the fixture, aged by however long the test took.
|
|
expect(within(status).getByText("Uptime").nextElementSibling?.textContent).toMatch(/^\d+s$/);
|
|
// The fixture's overview body: no queries over a 24h window, stated as a rate.
|
|
await waitFor(() => expect(within(status).getByText("Queries/min")).toBeTruthy());
|
|
expect(within(status).getByText("Queries/min").nextElementSibling?.textContent).toBe("0.0");
|
|
});
|
|
|
|
test("Pause sits at the foot of the sidebar, above the version label", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
const aside = document.querySelector("aside") as HTMLElement;
|
|
const pause = await waitFor(() => within(aside).getByRole("button", { name: "Pause" }));
|
|
const version = within(aside).getByText(/^nxdns v/);
|
|
// Node order, not styling: the control precedes the version footer.
|
|
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
|
|
test("the mobile drawer carries the same control, not a header one it lost", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(1));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
|
|
|
|
// Both renderings are mounted; the viewport decides which is painted.
|
|
await waitFor(() => expect(screen.getAllByRole("button", { name: "Pause" })).toHaveLength(2));
|
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
|
const pause = within(drawer).getByRole("button", { name: "Pause" });
|
|
const version = within(drawer).getByText(/^nxdns v/);
|
|
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
|
|
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
const menu = screen.getByRole("button", { name: "Menu" });
|
|
|
|
// The trigger states the drawer's state, and points at the drawer itself.
|
|
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
|
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
|
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
|
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
|
|
|
fireEvent.click(menu);
|
|
expect(menu.getAttribute("aria-expanded")).toBe("true");
|
|
expect(drawer.getAttribute("hidden")).toBeNull();
|
|
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
|
|
|
|
fireEvent.click(menu);
|
|
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
|
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
|
});
|
|
|
|
test("following a drawer link closes the drawer behind it", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
const menu = screen.getByRole("button", { name: "Menu" });
|
|
fireEvent.click(menu);
|
|
|
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
|
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
|
|
|
|
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
|
|
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
|
});
|
|
|
|
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
|
|
// The trace a pause leaves on every page. With the header indicator and the
|
|
// Overview status row both gone, a reader who is not on Diagnostics has only
|
|
// this line to tell them filtering is off.
|
|
const until = Math.floor(Date.now() / 1000) + 90;
|
|
healthBody = health({ protection: { state: "paused", until } });
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
|
|
const aside = document.querySelector("aside") as HTMLElement;
|
|
await waitFor(() => expect(within(aside).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Menu" }));
|
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
|
await waitFor(() => expect(within(drawer).getByText(`Paused until ${formatClock(until)}`)).toBeTruthy());
|
|
});
|
|
|
|
test("nothing open and a healthy rollup leaves the Diagnostics item unbadged", async () => {
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
await waitFor(() => expect(document.querySelector("aside")?.textContent).toContain("Diagnostics"));
|
|
expect(diagnosticsBadgeText()).toBeNull();
|
|
});
|
|
|
|
test("open episodes are counted on the nav item", async () => {
|
|
healthBody = health({ diagnostics: { state: "recording", active_warnings: 1, active_errors: 2 } });
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
await waitFor(() => expect(diagnosticsBadgeText()).toBe("3"));
|
|
expect(screen.getAllByLabelText("3 active diagnostic events").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("a degraded rollup with nothing open is still marked, and a failed poll too", async () => {
|
|
healthBody = health({ status: "degraded" });
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
|
|
expect(screen.getAllByLabelText("Health degraded").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("a health poll that failed is marked unknown rather than left looking healthy", async () => {
|
|
healthBody = null;
|
|
renderShell();
|
|
await screen.findByRole("heading", { name: "Overview" });
|
|
await waitFor(() => expect(diagnosticsBadgeText()).toBe("!"));
|
|
expect(screen.getAllByLabelText("Health unavailable").length).toBeGreaterThan(0);
|
|
});
|