the header row survives only on narrow screens; on desktop its lone occupant, log out, joins pause in the sidebar footer, both full width. pause leaves the query detail page's related actions, where a global control had no business, and its hand-rolled duration dropdown becomes a react-aria menu with real keyboard navigation, dismissal and positioning.
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import { 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 type { QueryDetail } from "@/lib/types";
|
|
import { provenance } from "@/features/provenance/provenanceFixture";
|
|
import { health } from "@/lib/healthFixture";
|
|
|
|
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
|
return { id, ...provenance(sections) };
|
|
}
|
|
|
|
let responses: Record<string, unknown>;
|
|
|
|
beforeEach(() => {
|
|
responses = {
|
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
|
"/api/health": health(),
|
|
};
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const payload = responses[String(input)];
|
|
if (payload === undefined) {
|
|
return new Response(JSON.stringify({ error: "no such query" }), {
|
|
status: 404,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
function renderDetail(id: number, search = "") {
|
|
const queryClient = createQueryClient();
|
|
const router = createAppRouter(
|
|
createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }),
|
|
queryClient,
|
|
);
|
|
render(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
return router;
|
|
}
|
|
|
|
/** The search parameters a link carries, so an assertion states them by name. */
|
|
function hrefSearch(link: HTMLElement): Record<string, string> {
|
|
const query = link.getAttribute("href")?.split("?")[1] ?? "";
|
|
return Object.fromEntries(new URLSearchParams(query));
|
|
}
|
|
|
|
/** The value beside a term, so a section's facts are read as pairs. */
|
|
function factValue(label: string): string {
|
|
const term = screen.getByText(label);
|
|
const value = term.nextElementSibling;
|
|
return value?.textContent ?? "";
|
|
}
|
|
|
|
/** The line under the domain, which is what a page is read as at a glance. */
|
|
function subtitle(domain: string): string {
|
|
const heading = screen.getByRole("heading", { name: domain });
|
|
return heading.nextElementSibling?.textContent ?? "";
|
|
}
|
|
|
|
test("a blocked query explains itself in the six sections, in pipeline order", async () => {
|
|
responses["/api/queries/42"] = detail(42, {
|
|
request: { time: 1_700_000_000, domain: "ads.example", client: "192.0.2.11", qtype: 28, qclass: 1 },
|
|
group: { id: 3, name: "kids" },
|
|
policy: {
|
|
action: "block",
|
|
reason: "blocklist_wildcard",
|
|
matched: "||tracker.example^",
|
|
source_id: 5,
|
|
source_name: "StevenBlack",
|
|
},
|
|
rewrites: { cname_target: "cdn.tracker.example" },
|
|
route: { kind: "blocked", upstream: "" },
|
|
response: { rcode: 0, duration_us: 1234 },
|
|
});
|
|
renderDetail(42);
|
|
|
|
await screen.findByRole("heading", { name: "ads.example" });
|
|
|
|
const headings = screen.getAllByRole("heading", { level: 2 }).map((node) => node.textContent);
|
|
expect(headings).toEqual(["Request", "Group", "Policy", "Rewrites", "Route", "Response", "Related"]);
|
|
|
|
expect(factValue("Client")).toBe("192.0.2.11");
|
|
expect(factValue("Type")).toBe("AAAA");
|
|
expect(factValue("Class")).toBe("IN (1)");
|
|
expect(factValue("Name")).toBe("kids");
|
|
expect(factValue("Id")).toBe("3");
|
|
expect(factValue("Decision")).toBe("Blocked");
|
|
expect(factValue("Reason")).toBe("Blocklist (wildcard)");
|
|
expect(factValue("Matched")).toBe("||tracker.example^");
|
|
expect(factValue("Blocklist")).toBe("StevenBlack (#5)");
|
|
expect(factValue("CNAME target")).toBe("cdn.tracker.example");
|
|
expect(factValue("Answered by")).toBe("Blocked locally");
|
|
expect(factValue("Upstream")).toBe("No upstream exchange");
|
|
expect(factValue("Result")).toBe("NOERROR (0)");
|
|
expect(factValue("Took")).toBe("1.2 ms");
|
|
// NOERROR is the ordinary case and adds nothing to the verdict.
|
|
expect(subtitle("ads.example")).toMatch(/ — Blocked$/);
|
|
});
|
|
|
|
test("an upstream SERVFAIL names the resolver that failed and the code the client saw", async () => {
|
|
responses["/api/queries/7"] = detail(7, {
|
|
request: { domain: "news.example" },
|
|
route: { kind: "upstream", upstream: "https://dns.example/dns-query" },
|
|
response: { rcode: 2, duration_us: null },
|
|
});
|
|
renderDetail(7);
|
|
|
|
await screen.findByRole("heading", { name: "news.example" });
|
|
expect(factValue("Answered by")).toBe("Upstream resolver");
|
|
expect(factValue("Upstream")).toBe("https://dns.example/dns-query");
|
|
expect(factValue("Result")).toBe("SERVFAIL (2)");
|
|
expect(factValue("Took")).toBe("Not measured");
|
|
// The policy allowed the query; the client still got nothing, and the
|
|
// headline has to say so rather than reading as a success.
|
|
expect(subtitle("news.example")).toMatch(/ — Allowed — SERVFAIL \(2\)$/);
|
|
});
|
|
|
|
test("a forward-zone answer says the matcher never ran, not that nothing matched", async () => {
|
|
responses["/api/queries/14"] = detail(14, {
|
|
request: { domain: "nas.lan.home" },
|
|
policy: { action: "allow", reason: "forward_zone", matched: "" },
|
|
route: { kind: "forward_zone", forward_zone: "lan.home", upstream: "udp://192.168.1.1:53" },
|
|
});
|
|
renderDetail(14);
|
|
|
|
await screen.findByRole("heading", { name: "nas.lan.home" });
|
|
expect(factValue("Reason")).toBe("Forward zone");
|
|
expect(factValue("Matched")).toBe("The matcher never ran");
|
|
});
|
|
|
|
test("a query the matcher did evaluate keeps the honest empty verdict", async () => {
|
|
responses["/api/queries/15"] = detail(15, {
|
|
policy: { action: "allow", reason: "no_match", matched: "" },
|
|
});
|
|
renderDetail(15);
|
|
|
|
await screen.findByRole("heading", { name: "example.com" });
|
|
expect(factValue("Reason")).toBe("No match");
|
|
expect(factValue("Matched")).toBe("Nothing matched");
|
|
});
|
|
|
|
test("empty text fields read as absent facts, never as blank values", async () => {
|
|
responses["/api/queries/8"] = detail(8, {
|
|
group: { id: null, name: "" },
|
|
policy: { action: "not_evaluated", reason: "paused", matched: "", source_id: null, source_name: "" },
|
|
route: { kind: "upstream", forward_zone: "", upstream: "udp://9.9.9.9:53" },
|
|
});
|
|
renderDetail(8);
|
|
|
|
await screen.findByRole("heading", { name: "example.com" });
|
|
expect(factValue("Name")).toBe("No group recorded");
|
|
expect(factValue("Matched")).toBe("The matcher never ran");
|
|
expect(factValue("Blocklist")).toBe("Not a blocklist decision");
|
|
expect(factValue("Safe search")).toBe("No rewrite");
|
|
expect(factValue("Reason")).toBe("Filtering paused");
|
|
});
|
|
|
|
test("a log with hidden domains renders the server's marker, with nothing invented around it", async () => {
|
|
responses["/api/queries/9"] = detail(9, {
|
|
request: { domain: "hidden" },
|
|
policy: { action: "block", reason: "blocklist_domain", matched: "hidden", source_name: "StevenBlack" },
|
|
rewrites: { cname_target: "hidden", safe_search_target: "hidden" },
|
|
route: { kind: "blocked", upstream: "" },
|
|
});
|
|
renderDetail(9);
|
|
|
|
await screen.findByRole("heading", { name: "hidden" });
|
|
expect(factValue("Domain")).toBe("hidden");
|
|
expect(factValue("Matched")).toBe("hidden");
|
|
expect(factValue("CNAME target")).toBe("hidden");
|
|
expect(factValue("Safe search")).toBe("hidden");
|
|
// The client is governed by its own flag and stays visible here.
|
|
expect(factValue("Client")).toBe("192.0.2.10");
|
|
});
|
|
|
|
test("the related actions carry absolute bounds around the query, and the domain into the simulation", async () => {
|
|
responses["/api/queries/11"] = detail(11, {
|
|
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
|
|
});
|
|
renderDetail(11);
|
|
|
|
await screen.findByRole("heading", { name: "shop.example" });
|
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
|
expect(
|
|
within(related)
|
|
.getByRole("link", { name: /Test this domain/ })
|
|
.getAttribute("href"),
|
|
).toBe("/activity/test?domain=shop.example");
|
|
// No origin bound at all: five minutes either side of the query itself.
|
|
expect(hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }))).toEqual({
|
|
mode: "history",
|
|
domain: "shop.example",
|
|
since: "1699999700",
|
|
until: "1700000300",
|
|
});
|
|
expect(hrefSearch(within(related).getByRole("link", { name: "All activity from this client" }))).toEqual({
|
|
mode: "history",
|
|
client: "192.0.2.12",
|
|
since: "1699999700",
|
|
until: "1700000300",
|
|
});
|
|
// The diagnostics window is the query's own moment, never the origin's.
|
|
expect(hrefSearch(within(related).getByRole("link", { name: /Diagnostics around/ }))).toEqual({
|
|
since: "1699999700",
|
|
until: "1700000300",
|
|
});
|
|
});
|
|
|
|
test("an origin bound wins over the default window, one bound at a time", async () => {
|
|
responses["/api/queries/17"] = detail(17, {
|
|
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
|
|
});
|
|
renderDetail(17, "?mode=history&since=1600000000");
|
|
|
|
await screen.findByRole("heading", { name: "shop.example" });
|
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
|
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
|
|
expect(link["since"]).toBe("1600000000");
|
|
expect(link["until"]).toBe("1700000300");
|
|
});
|
|
|
|
test("both origin bounds carry through untouched", async () => {
|
|
responses["/api/queries/18"] = detail(18, { request: { time: 1_700_000_000, domain: "shop.example" } });
|
|
renderDetail(18, "?mode=history&since=1600000000&until=1600000060");
|
|
|
|
await screen.findByRole("heading", { name: "shop.example" });
|
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
|
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
|
|
expect(link["since"]).toBe("1600000000");
|
|
expect(link["until"]).toBe("1600000060");
|
|
});
|
|
|
|
test("the back link restores the investigation the reader came from", async () => {
|
|
responses["/api/queries/19"] = detail(19, { request: { domain: "shop.example" } });
|
|
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
|
|
|
|
await screen.findByRole("heading", { name: "shop.example" });
|
|
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
|
mode: "history",
|
|
domain: "shop",
|
|
since: "1600000000",
|
|
blocked: "true",
|
|
});
|
|
});
|
|
|
|
function clientList(client: { ip: string; name: string; learned_name: string }) {
|
|
return {
|
|
clients: [
|
|
{
|
|
id: 1,
|
|
group_id: 1,
|
|
group: "default",
|
|
hand_edited: client.name !== "",
|
|
first_seen: 1_700_000_000,
|
|
last_seen: 1_700_000_100,
|
|
...client,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** The related section, whose text is read whole because it is prose, not facts. */
|
|
async function relatedText(expected: string) {
|
|
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
|
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
|
|
}
|
|
|
|
test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
|
|
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
|
|
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
|
|
renderDetail(12);
|
|
|
|
await screen.findByRole("heading", { name: "shop.example" });
|
|
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
|
|
expect(factValue("Client")).toBe("192.0.2.12");
|
|
});
|
|
|
|
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
|
|
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
|
|
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
|
|
renderDetail(13);
|
|
|
|
await screen.findByRole("heading", { name: "example.com" });
|
|
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
|
|
expect(factValue("Client")).toBe("192.0.2.13");
|
|
});
|
|
|
|
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
|
|
renderDetail(404, "?mode=history&domain=gone");
|
|
|
|
await screen.findByRole("alert");
|
|
expect(screen.getByText(/no such query/)).toBeTruthy();
|
|
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
|
mode: "history",
|
|
domain: "gone",
|
|
});
|
|
});
|
|
|
|
/** The related-actions region of a query detail. */
|
|
function related(): HTMLElement {
|
|
return screen.getByRole("region", { name: "Related" });
|
|
}
|
|
|
|
/**
|
|
* Related is links only. Pause is a resolver-wide control and lives in the
|
|
* sidebar alone, so a blocked query — the case that used to carry one here —
|
|
* offers no button of any kind.
|
|
*/
|
|
test("Related carries its four links and no control, blocked query or not", async () => {
|
|
responses["/api/queries/50"] = detail(50, {
|
|
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
|
route: { kind: "blocked", upstream: "" },
|
|
});
|
|
renderDetail(50);
|
|
|
|
await screen.findByRole("heading", { name: "example.com" });
|
|
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
|
expect(within(related()).getAllByRole("link").map((link) => link.textContent)).toEqual([
|
|
"Test this domain against current policy",
|
|
"All activity for this domain",
|
|
"All activity from this client",
|
|
"Diagnostics around this query",
|
|
]);
|
|
expect(within(related()).queryByRole("button")).toBeNull();
|
|
});
|