Files
nxdns/admin/src/features/diagnostics/DiagnosticsPage.test.tsx
T

379 lines
15 KiB
TypeScript

import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";
// Ages are rendered against the wall clock, so the fixtures are anchored to it
// rather than to a frozen instant: faking time here would fight the query
// client's own timers for no gain.
const NOW_S = Math.floor(Date.now() / 1000);
function event(id: number, overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
return {
id,
code: "blocklist.refresh",
component: "blocklist",
subject: "StevenBlack",
severity: "warning",
first_seen: NOW_S - 3600,
last_seen: NOW_S - 300,
occurrences: 3,
resolved_at: null,
detail: "download failed: ConnectionTimedOut",
...overrides,
};
}
function page(events: DiagnosticEvent[], nextBefore: number | null = null): DiagnosticsPage {
return { events, next_before: nextBefore, active: { warnings: 1, errors: 1 } };
}
const ACTIVE = page([
event(42),
event(41, {
code: "upstream.exchange",
component: "upstream",
subject: "tls://dns.example:853",
severity: "error",
occurrences: 1,
}),
]);
const RESOLVED = page([
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
]);
/** A stubbed response that carries a non-200 status instead of a payload. */
class Failure {
constructor(
readonly status: number,
readonly body: unknown,
) {}
}
function fail(status: number, message: string): Failure {
return new Failure(status, { error: message });
}
/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
const NO_CONTENT = Symbol("204");
let responses: Record<string, unknown>;
let requested: string[];
beforeEach(() => {
requested = [];
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
// The health strip at the top of the page; quiet on a healthy box, which is
// what every test below wants it to be.
"/api/health": health(),
"/api/diagnostics?state=active": ACTIVE,
"/api/diagnostics?state=resolved": RESOLVED,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
const url = String(input);
const method = init?.method ?? "GET";
// Reads stay keyed by url alone, so the assertions below read as the
// request line they are; writes carry their method.
const key = method === "GET" ? url : `${method} ${url}`;
requested.push(key);
const payload = responses[key];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
if (payload === NO_CONTENT) return new Response(null, { status: 204 });
if (payload instanceof Failure) {
return new Response(JSON.stringify(payload.body), {
status: payload.status,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* `retry` is off in the failure tests: the shared client backs 5xx off for
* seconds, which the render assertions would sit through for nothing.
*/
function renderRoute(path = "/diagnostics", { retry = true } = {}) {
const queryClient = createQueryClient();
if (!retry) {
const defaults = queryClient.getDefaultOptions();
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
}
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
/** A RAC Select names its trigger with the current value and then the label. */
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function pick(label: string, option: string) {
fireEvent.click(trigger(label));
fireEvent.click(await screen.findByRole("option", { name: option }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}
test("active episodes come first, each with its title, subject, age and count", async () => {
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
const active = screen.getByText("Blocklist source failed to update").closest("li")!;
expect(within(active).getByText("Warning")).toBeTruthy();
expect(within(active).getByText("StevenBlack")).toBeTruthy();
expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();
const failing = screen.getByText("Upstream failing").closest("li")!;
expect(within(failing).getByText("Error")).toBeTruthy();
expect(within(failing).getByText(/1 occurrence(?!s)/)).toBeTruthy();
// The resolved history is a separate section, below the active list.
const table = within(screen.getByRole("table"));
expect(table.getByText("Disk space low")).toBeTruthy();
expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
});
test("nothing open reads as good news, not as a broken page", async () => {
responses["/api/diagnostics?state=active"] = page([]);
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
const healthy = await screen.findByText("No active operational issues.");
expect(healthy.getAttribute("role")).toBe("status");
// Quiet: no alert anywhere on the page, and no empty table standing in.
expect(screen.queryByRole("alert")).toBeNull();
});
test("a filter lands in the url and refetches both sections through it", async () => {
responses["/api/diagnostics?severity=error&state=active"] = page([
event(41, { code: "upstream.exchange", component: "upstream", severity: "error" }),
]);
responses["/api/diagnostics?severity=error&state=resolved"] = page([]);
const router = renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
await pick("Severity", "Errors");
await waitFor(() => expect(router.state.location.search).toEqual({ severity: "error" }));
await waitFor(() => expect(screen.queryByText("Blocklist source failed to update")).toBeNull());
expect(requested).toContain("/api/diagnostics?severity=error&state=active");
expect(requested).toContain("/api/diagnostics?severity=error&state=resolved");
});
test("the state filter hides the section it excludes", async () => {
const router = renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
await pick("Show", "Active only");
await waitFor(() => expect(router.state.location.search).toEqual({ state: "active" }));
expect(screen.queryByRole("heading", { name: "Resolved" })).toBeNull();
expect(screen.getByRole("heading", { name: "Active" })).toBeTruthy();
});
test("a url written by hand starts on the filters it names", async () => {
responses["/api/diagnostics?component=disk&state=resolved"] = RESOLVED;
renderRoute("/diagnostics?state=resolved&component=disk");
await screen.findByRole("heading", { name: "Diagnostics" });
await screen.findByText("Disk space low");
expect(screen.queryByRole("heading", { name: "Active" })).toBeNull();
expect(requested).toContain("/api/diagnostics?component=disk&state=resolved");
});
test("load more appends the next page of resolved history", async () => {
responses["/api/diagnostics?state=resolved"] = page(
[event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 })],
30,
);
responses["/api/diagnostics?state=resolved&before=30"] = page([
event(12, {
code: "certificate.reload",
component: "certificate",
subject: "doh",
resolved_at: NOW_S - 90_000,
}),
]);
renderRoute();
await screen.findByText("Disk space low");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("TLS certificate reload failed");
expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
});
test("an unavailable store reports the failure instead of loading forever", async () => {
responses["/api/diagnostics?state=active"] = fail(503, "store unavailable");
renderRoute("/diagnostics", { retry: false });
await screen.findByRole("heading", { name: "Diagnostics" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("The server is starting or degraded.");
expect(screen.queryByText("Loading diagnostics…")).toBeNull();
// The resolved section answered, so it still renders its own history.
expect(screen.getByText("Disk space low")).toBeTruthy();
});
test("a failed history query reports the failure and retries on demand", async () => {
responses["/api/diagnostics?state=resolved"] = fail(500, "diagnostics store read failed");
renderRoute("/diagnostics", { retry: false });
await screen.findByRole("heading", { name: "Diagnostics" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("diagnostics store read failed");
expect(screen.queryByText("Loading history…")).toBeNull();
responses["/api/diagnostics?state=resolved"] = RESOLVED;
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await screen.findByText("Disk space low");
expect(screen.queryByRole("alert")).toBeNull();
});
test("only the resolved history offers a purge", async () => {
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
// An episode still failing is the state of the box, not history: no purge
// affordance anywhere on its card.
const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!;
expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();
const row = screen.getByText("Disk space low").closest("tr")!;
expect(within(row).getByRole("button", { name: "Purge" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Purge all resolved" })).toBeTruthy();
});
test("with no resolved history there is nothing to purge in bulk", async () => {
responses["/api/diagnostics?state=resolved"] = page([]);
renderRoute();
await screen.findByRole("heading", { name: "Diagnostics" });
await screen.findByText("Nothing has failed and recovered in the retained window.");
expect(screen.queryByRole("button", { name: "Purge all resolved" })).toBeNull();
});
test("purging one row asks first, then sends the DELETE and refetches the lists", async () => {
responses["DELETE /api/diagnostics/30"] = NO_CONTENT;
renderRoute();
await screen.findByText("Disk space low");
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(requested).not.toContain("DELETE /api/diagnostics/30");
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/30"));
// The invalidation covers both sections: the page the row left and the
// active list, whose `active` counts come from the same table.
await waitFor(() =>
expect(requested.filter((url) => url === "/api/diagnostics?state=resolved").length).toBeGreaterThan(1),
);
await waitFor(() =>
expect(requested.filter((url) => url === "/api/diagnostics?state=active").length).toBeGreaterThan(1),
);
});
test("purging the whole history asks first and sends one DELETE", async () => {
responses["DELETE /api/diagnostics"] = { purged: 1 };
renderRoute();
await screen.findByText("Disk space low");
fireEvent.click(screen.getByRole("button", { name: "Purge all resolved" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain("Purge all resolved events? Active events are kept.");
// What the server will answer once the purge has landed; the refetch the
// mutation triggers is what has to pick it up.
responses["/api/diagnostics?state=resolved"] = page([]);
fireEvent.click(within(dialog).getByRole("button", { name: "Purge all" }));
await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics"));
await waitFor(() => expect(screen.queryByText("Disk space low")).toBeNull());
expect(screen.getByText("Blocklist source failed to update")).toBeTruthy();
});
test("a refused purge reports the server's reason and keeps the row", async () => {
responses["DELETE /api/diagnostics/30"] = fail(409, "the event is still active; it can be purged once it resolves");
renderRoute("/diagnostics", { retry: false });
await screen.findByText("Disk space low");
fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("the event is still active");
expect(screen.getByText("Disk space low")).toBeTruthy();
});
test("an episode links to its own detail page", async () => {
responses["/api/diagnostics/42"] = event(42);
renderRoute();
const link = await screen.findByRole("link", { name: "Blocklist source failed to update" });
expect(link.getAttribute("href")).toBe("/diagnostics/42");
});
test("an absolute window reaches both requests and is stated on the page", async () => {
const bounded = "since=1699999700&until=1700000300";
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
renderRoute(`/diagnostics?${bounded}`);
await screen.findByRole("heading", { name: "Active" });
expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`);
expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`);
// An empty section inside a five-minute window means something different
// from an empty section over the whole history, so the page has to say so.
expect(screen.getByText(/Showing events that overlap/)).toBeTruthy();
});
test("clearing the range drops both bounds from the url", async () => {
const bounded = "since=1699999700&until=1700000300";
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
const router = renderRoute(`/diagnostics?${bounded}`);
fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" }));
await waitFor(() => {
expect(router.state.location.search).not.toContain("since");
});
expect(router.state.location.search).not.toContain("until");
});
test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => {
renderRoute("/diagnostics?since=1.5&until=Infinity");
await screen.findByRole("heading", { name: "Active" });
expect(requested).toContain("/api/diagnostics?state=active");
expect(screen.queryByText(/Showing events that overlap/)).toBeNull();
});