/** * Activity in history mode, through the real router: the URL is the applied * state, so nothing here can be checked by rendering the page on its own. */ import { act, 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 { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types"; import { queryRow } from "@/features/provenance/provenanceFixture"; function client(id: number, ip: string, name: string, learnedName: string): Client { return { id, ip, name, learned_name: learnedName, group_id: 1, group: "default", hand_edited: name !== "", first_seen: 1_700_000_000, last_seen: 1_700_000_100, }; } const CLIENTS: Client[] = [ client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"), client(2, "192.0.2.11", "", "laptop.lan"), client(3, "192.0.2.12", "", ""), ]; function row(id: number, domain: string, overrides: Partial = {}): QueryRow { return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides }); } const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 }; /** The blocked row every page fixture reuses. */ const BLOCKED = { blocked: true, policy_action: "block", policy_reason: "blocklist_wildcard", route_kind: "blocked", upstream: "", } as const satisfies Partial; const PAGES: Record = { "/api/queries": { queries: [ row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }), row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }), ], next_before: 19, coverage: COMPLETE, }, "/api/queries?before=19": { queries: [row(5, "older.example")], next_before: null, coverage: COMPLETE, }, "/api/queries?domain=ads": { queries: [row(19, "ads.example", BLOCKED)], next_before: null, coverage: COMPLETE, }, "/api/queries?domain=ads&blocked=true": { queries: [row(19, "ads.example", BLOCKED)], next_before: null, coverage: COMPLETE, }, "/api/queries?since=1700000000": { queries: [row(20, "first.example")], next_before: null, coverage: COMPLETE, }, }; let fetchMock: ReturnType; function json(payload: unknown): Response { return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } }); } const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; /** The shell's own requests, which every test serves the same way. */ function stubFetch(handler: (url: string) => Response | Promise) { fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url === "/api/version") return Promise.resolve(json(VERSION)); // The shell reads health on every route for the Diagnostics nav badge. if (url === "/api/health") return Promise.resolve(json(health())); return Promise.resolve(handler(url)); }); vi.stubGlobal("fetch", fetchMock); } function fromPages(url: string): Response { if (url === "/api/clients") return json({ clients: CLIENTS }); const payload = PAGES[url]; if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); return json(payload); } beforeEach(() => { stubFetch(fromPages); }); afterEach(() => { vi.unstubAllGlobals(); }); function renderPage(path = "/activity") { const queryClient = createQueryClient(); const history = createMemoryHistory({ initialEntries: [path] }); const router = createAppRouter(history, queryClient); render( , ); return { queryClient, history }; } /** Every `/api/queries` URL the run asked for, list pages only. */ function queryCalls(): string[] { return fetchMock.mock.calls .map((call) => String(call[0])) .filter((url) => url === "/api/queries" || url.startsWith("/api/queries?")); } test("renders the first page with the seven columns filled in", async () => { renderPage(); await screen.findByText("first.example"); expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([ "Time", "Domain", "Client", "Type", "Result", "Route", "Duration", ]); const first = screen.getByText("first.example").closest("tr")!; expect(within(first).getByText("HTTPS")).toBeTruthy(); expect(within(first).getByText("NOERROR")).toBeTruthy(); expect(within(first).getByText("Cache")).toBeTruthy(); expect(within(first).getByText("1.2 ms")).toBeTruthy(); const blocked = screen.getByText("ads.example").closest("tr")!; // The Result cell says Blocked even though the client saw NOERROR, and the // Route cell says how: this is the pair the old Status column could not show. expect(within(blocked).getAllByText("Blocked")).toHaveLength(2); expect(within(blocked).getByText("—")).toBeTruthy(); expect(screen.getByText(/Showing 2 queries/)).toBeTruthy(); }); test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => { stubFetch((url) => { if (url === "/api/clients") return json({ clients: CLIENTS }); if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); return json({ queries: [ row(20, "named.example", { client_ip: "192.0.2.10" }), row(19, "learned.example", { client_ip: "192.0.2.11" }), row(18, "nameless.example", { client_ip: "192.0.2.12" }), row(17, "stranger.example", { client_ip: "192.0.2.99" }), ], next_before: null, coverage: COMPLETE, } satisfies QueriesPage); }); renderPage(); // A hand-typed name wins outright; the learned name never surfaces for it. const named = await screen.findByText("Kitchen Pi"); expect(named.getAttribute("title")).toBe("192.0.2.10"); expect(screen.queryByText("pi.lan")).toBeNull(); // A learned name reads muted and nothing more here: the "learned" tag would // repeat on every row of the table, so the Clients page carries it instead. const learned = screen.getByText("laptop.lan"); expect(learned.getAttribute("title")).toBe("192.0.2.11"); expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull(); // A known client with neither name, and a client the loaded list has never // seen, both fall back to the bare address with no tooltip standing in. expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull(); expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull(); }); test("load more appends the next page and stops at the end of the log", async () => { renderPage(); await screen.findByText("first.example"); fireEvent.click(screen.getByRole("button", { name: "Load more" })); await screen.findByText("older.example"); expect(screen.getByText("first.example")).toBeTruthy(); expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy(); expect(screen.queryByRole("button", { name: "Load more" })).toBeNull(); }); test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => { const { history } = renderPage(); await screen.findByText("first.example"); fireEvent.click(screen.getByRole("button", { name: "Load more" })); await screen.findByText("older.example"); fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); await screen.findByText(/Showing 1 query /); expect(history.location.search).toContain("domain=ads"); expect(screen.getByText("ads.example")).toBeTruthy(); expect(screen.queryByText("first.example")).toBeNull(); expect(screen.queryByText("older.example")).toBeNull(); }); test("a load-more that resolves after a filter change is discarded", async () => { let releaseLoadMore: () => void = () => {}; stubFetch((url) => { if (url === "/api/queries?before=19") { return new Promise((resolve) => { releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"])); }); } return fromPages(url); }); renderPage(); await screen.findByText("first.example"); fireEvent.click(screen.getByRole("button", { name: "Load more" })); fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); await screen.findByText(/Showing 1 query /); releaseLoadMore(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(screen.queryByText("older.example")).toBeNull(); expect(screen.getByText(/Showing 1 query /)).toBeTruthy(); expect(screen.queryByRole("alert")).toBeNull(); }); test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => { let releaseFiltered: () => void = () => {}; const filteredPage: QueriesPage = { queries: [row(19, "ads.example", BLOCKED)], next_before: 7, coverage: COMPLETE, }; const filteredOlderPage: QueriesPage = { queries: [row(3, "ads.older.example")], next_before: null, coverage: COMPLETE, }; stubFetch((url) => { if (url === "/api/queries?domain=ads") { return new Promise((resolve) => { releaseFiltered = () => resolve(json(filteredPage)); }); } if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage); return fromPages(url); }); renderPage(); await screen.findByText("first.example"); fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); const staleButton = await screen.findByRole("button", { name: "Load more" }); expect(staleButton).toHaveProperty("disabled", true); fireEvent.click(staleButton); expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19"); releaseFiltered(); await waitFor(() => { expect(screen.queryByText("first.example")).toBeNull(); }); const freshButton = screen.getByRole("button", { name: "Load more" }); expect(freshButton).toHaveProperty("disabled", false); fireEvent.click(freshButton); await screen.findByText("ads.older.example"); expect(queryCalls()).toContain("/api/queries?domain=ads&before=7"); expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy(); }); test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => { // The newest-100 window moves up while the reader has a second page open. // Refetching only the first page would drop n20 and n19 out of the middle // of the table; the second page must be replayed from the fresh cursor. const before: Record = { "/api/queries": { queries: [row(20, "n20.example"), row(19, "n19.example")], next_before: 19, coverage: COMPLETE, }, "/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null, coverage: COMPLETE, }, }; const after: Record = { "/api/queries": { queries: [row(22, "n22.example"), row(21, "n21.example")], next_before: 21, coverage: COMPLETE, }, "/api/queries?before=21": { queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")], next_before: null, coverage: COMPLETE, }, }; let live = before; stubFetch((url) => { const payload = live[url]; if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); return json(payload); }); const { queryClient } = renderPage(); await screen.findByText("n20.example"); fireEvent.click(screen.getByRole("button", { name: "Load more" })); await screen.findByText("n17.example"); live = after; await act(async () => { await queryClient.invalidateQueries({ queryKey: ["queries"] }); }); await screen.findByText("n22.example"); const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent); expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]); expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy(); }); test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => { const assign = vi.fn(); vi.stubGlobal("location", { pathname: "/activity", search: "", assign }); stubFetch((url) => { if (url === "/api/queries?before=19") { return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" }, }); } return fromPages(url); }); renderPage(); await screen.findByText("first.example"); fireEvent.click(screen.getByRole("button", { name: "Load more" })); await waitFor(() => { expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`); }); expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByText(/Failed to load more/)).toBeNull(); }); test("each row links into its own detail page, carrying the investigation with it", async () => { renderPage("/activity?mode=history&since=1700000000"); await screen.findByText("first.example"); const link = screen.getByRole("link", { name: "first.example" }); expect(link.getAttribute("href")).toContain("/activity/queries/20"); expect(link.getAttribute("href")).toContain("since=1700000000"); // An is in the tab order by default; nothing here may opt it out. expect(link.getAttribute("tabindex")).toBeNull(); }); test("a pruned window tells the reader when history starts", async () => { stubFetch((url) => { if (url === "/api/clients") return json({ clients: CLIENTS }); if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); return json({ queries: [row(20, "kept.example")], next_before: null, coverage: { complete: false, available_since: 1_700_000_000 }, } satisfies QueriesPage); }); renderPage(); await screen.findByText("kept.example"); expect(screen.getByText(/Query history is available from/)).toBeTruthy(); }); test("a complete window shows no coverage notice", async () => { renderPage(); await screen.findByText("first.example"); expect(screen.queryByText(/Query history is available from/)).toBeNull(); }); test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => { renderPage("/activity?domain=ads"); await screen.findByText("ads.example"); expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads"); expect(screen.queryByText("first.example")).toBeNull(); }); test("history forwards exactly the six normalized filter fields and nothing else", async () => { stubFetch((url) => { if (url === "/api/clients") return json({ clients: CLIENTS }); return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); }); renderPage( "/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999", ); await screen.findByText("No queries match the current filters."); expect(queryCalls()).toEqual([ "/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600", ]); }); test("a rejected search parameter is dropped rather than guessed at", async () => { renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20"); await screen.findByText("first.example"); // Nothing survived validation, so the request is the unfiltered one. expect(queryCalls()).toEqual(["/api/queries"]); expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", ""); }); test("the form draft follows the url back and forward, seconds included", async () => { stubFetch((url) => { if (url === "/api/clients") return json({ clients: CLIENTS }); return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); }); // A bound with non-zero seconds: the round trip has to keep them, and the // untouched field has to carry the original number rather than re-parse. const seeded = 1_700_000_017; const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`); const domainInput = await screen.findByLabelText("Domain contains"); expect(domainInput).toHaveProperty("value", "first"); const sinceInput = screen.getByLabelText("Since") as HTMLInputElement; expect(sinceInput.value).toContain(":37"); fireEvent.change(domainInput, { target: { value: "second" } }); fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); await waitFor(() => expect(history.location.search).toContain("domain=second")); // The untouched Since bound applied as the exact second it was seeded with. expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`); act(() => history.back()); await waitFor(() => { expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first"); }); act(() => history.forward()); await waitFor(() => { expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second"); }); }); /** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */ const DST_WALL_TIME = "2026-03-29T02:30:00"; /** * Whether that wall time names an instant in the timezone the suite runs in. * `new Date` slides a spring-forward gap silently forward, so an hour or minute * that comes back different from the one written *is* the gap. */ function wallTimeExists(text: string): boolean { const written = /T(\d{2}):(\d{2})/.exec(text)!; const parsed = new Date(text); return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]); } test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => { // One expected outcome per timezone, decided here rather than accepted from // the page: in a zone with the jump the bound must be refused outright, and // in a zone without it the same text is an ordinary instant that applies. const inGap = !wallTimeExists(DST_WALL_TIME); const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000); stubFetch((url) => { if (url === `/api/queries?since=${unix}`) { return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); } return fromPages(url); }); const { history } = renderPage(); await screen.findByText("first.example"); const callsBefore = queryCalls().length; const searchBefore = history.location.search; fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } }); fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); if (inGap) { expect(screen.getByRole("alert").textContent).toContain("daylight saving"); // Refused means refused: no navigation, and no request for the hour the // operator did not ask for. expect(history.location.search).toBe(searchBefore); expect(queryCalls()).toHaveLength(callsBefore); return; } await waitFor(() => expect(history.location.search).toContain(`since=${unix}`)); expect(queryCalls()).toContain(`/api/queries?since=${unix}`); expect(screen.queryByRole("alert")).toBeNull(); }); test("the policy simulation is reachable from the header, with no rows to click through", async () => { stubFetch((url) => { if (url === "/api/clients") return json({ clients: CLIENTS }); if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] }); return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); }); const { history } = renderPage(); // An empty log is exactly the case a row-borne link cannot serve. await screen.findByText("No queries logged yet."); const link = screen.getByRole("link", { name: "Current policy simulation" }); expect(link.getAttribute("href")).toBe("/activity/test"); fireEvent.click(link); await screen.findByRole("heading", { level: 1, name: "Current policy simulation" }); expect(history.location.pathname).toBe("/activity/test"); }); test("Clear empties the url as well as the form", async () => { const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true"); await screen.findByText("ads.example"); fireEvent.click(screen.getByRole("button", { name: "Clear" })); await waitFor(() => { expect(history.location.search).not.toContain("domain"); }); expect(history.location.search).not.toContain("blocked"); expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", ""); });