milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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/queries/provenanceFixture";
|
||||
|
||||
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 },
|
||||
};
|
||||
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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user