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.
798 lines
29 KiB
TypeScript
798 lines
29 KiB
TypeScript
/**
|
|
* 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";
|
|
import { FakeEventSource } from "./fakeEventSource";
|
|
|
|
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> = {}): 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<QueryRow>;
|
|
|
|
const PAGES: Record<string, QueriesPage> = {
|
|
"/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<typeof vi.fn>;
|
|
|
|
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", 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<Response>) {
|
|
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(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
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?"));
|
|
}
|
|
|
|
/** The toolbar's search field, which is how a domain filter is entered now. */
|
|
function domainInput(): HTMLInputElement {
|
|
return screen.getByLabelText("Filter domains") as HTMLInputElement;
|
|
}
|
|
|
|
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
|
|
function submitFilters() {
|
|
fireEvent.submit(domainInput().closest("form")!);
|
|
}
|
|
|
|
/**
|
|
* The footer's count line, matched on the whole sentence.
|
|
*
|
|
* The number sits in a span of its own so it can carry tabular digits, so the
|
|
* line is several text nodes and the default string matcher — which reads one
|
|
* node at a time — cannot see it whole.
|
|
*/
|
|
function countMatcher(pattern: RegExp) {
|
|
return (_: string, element: Element | null): boolean =>
|
|
element?.tagName === "P" && pattern.test(element.textContent ?? "");
|
|
}
|
|
|
|
function countLine(pattern: RegExp): HTMLElement {
|
|
return screen.getByText(countMatcher(pattern));
|
|
}
|
|
|
|
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
|
|
return screen.findByText(countMatcher(pattern));
|
|
}
|
|
|
|
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
|
function openCustomRange() {
|
|
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
|
}
|
|
|
|
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(countLine(/Showing 2 queries/)).toBeTruthy();
|
|
});
|
|
|
|
test("resolves each row's client to its display name, reading the IP out with it", 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");
|
|
// The address reads out with the name it replaced, rather than sitting in a
|
|
// title only a mouse can reach.
|
|
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
|
expect(named.getAttribute("title")).toBeNull();
|
|
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.textContent).toBe("laptop.lan (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 nothing standing in for it.
|
|
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
|
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
|
});
|
|
|
|
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(countLine(/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(domainInput(), { target: { value: "ads" } });
|
|
submitFilters();
|
|
|
|
await findCountLine(/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<Response>((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(domainInput(), { target: { value: "ads" } });
|
|
submitFilters();
|
|
await findCountLine(/Showing 1 query /);
|
|
|
|
releaseLoadMore();
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
|
|
expect(screen.queryByText("older.example")).toBeNull();
|
|
expect(countLine(/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<Response>((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(domainInput(), { target: { value: "ads" } });
|
|
submitFilters();
|
|
|
|
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(countLine(/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<string, QueriesPage> = {
|
|
"/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<string, QueriesPage> = {
|
|
"/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(countLine(/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 <a href> 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(domainInput()).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(domainInput()).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}`);
|
|
|
|
await screen.findByLabelText("Filter domains");
|
|
expect(domainInput()).toHaveProperty("value", "first");
|
|
// A seeded custom range opens its row, so the link's bounds are visible.
|
|
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
|
expect(sinceInput.value).toContain(":37");
|
|
|
|
fireEvent.change(domainInput(), { target: { value: "second" } });
|
|
submitFilters();
|
|
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}`);
|
|
|
|
// A pasted link, then the buttons over it: the draft is derived from the URL,
|
|
// so whichever way the browser moves it the field has to move with it.
|
|
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
|
|
await waitFor(() => {
|
|
expect(domainInput()).toHaveProperty("value", "third");
|
|
});
|
|
|
|
act(() => history.back());
|
|
await waitFor(() => {
|
|
expect(domainInput()).toHaveProperty("value", "second");
|
|
});
|
|
|
|
act(() => history.forward());
|
|
await waitFor(() => {
|
|
expect(domainInput()).toHaveProperty("value", "third");
|
|
});
|
|
});
|
|
|
|
/** 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;
|
|
|
|
openCustomRange();
|
|
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
|
|
|
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("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
|
|
// Live opens a stream as soon as its panel mounts, so the switch cannot be
|
|
// exercised without one; the fake stands in for the browser's EventSource.
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
class {
|
|
constructor(url: string) {
|
|
return new FakeEventSource(url) as unknown as EventSource;
|
|
}
|
|
},
|
|
);
|
|
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
|
await screen.findByText("ads.example");
|
|
|
|
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
|
|
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
|
|
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
|
|
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
|
|
|
|
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
|
|
|
|
await waitFor(() => expect(history.location.search).toContain("mode=live"));
|
|
// The investigation survives the switch: both filters are still in the URL.
|
|
expect(history.location.search).toContain("domain=ads");
|
|
expect(history.location.search).toContain("blocked=true");
|
|
expect(
|
|
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
|
|
name: "Live",
|
|
selected: true,
|
|
}),
|
|
).toBeTruthy();
|
|
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
|
|
});
|
|
|
|
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(domainInput()).toHaveProperty("value", "");
|
|
});
|
|
|
|
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
|
|
stubFetch((url) => {
|
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
|
if (url === "/api/queries") return json(PAGES["/api/queries"]);
|
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
|
});
|
|
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
|
|
await screen.findByText("No queries match the current filters.");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
|
|
|
await waitFor(() => {
|
|
expect(history.location.search).not.toContain("domain");
|
|
});
|
|
expect(history.location.search).not.toContain("blocked");
|
|
expect(domainInput()).toHaveProperty("value", "");
|
|
// The unfiltered log is back, so the button did the toolbar's Clear and not a
|
|
// reset of its own that only emptied the form.
|
|
expect(await screen.findByText("first.example")).toBeTruthy();
|
|
});
|
|
|
|
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
|
|
stubFetch((url) => {
|
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
|
});
|
|
renderPage();
|
|
|
|
await screen.findByText("No queries logged yet.");
|
|
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
|
|
});
|
|
|
|
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
const { history } = renderPage();
|
|
await screen.findByText("first.example");
|
|
|
|
fireEvent.change(domainInput(), { target: { value: "a" } });
|
|
fireEvent.change(domainInput(), { target: { value: "ad" } });
|
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
|
// Mid-word the URL has not moved: three keystrokes are one investigation,
|
|
// not three, and each one would otherwise be a request and a history entry.
|
|
expect(history.location.search).not.toContain("domain");
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
});
|
|
expect(history.location.search).toContain("domain=ads");
|
|
// Replaced, not pushed: Back leaves the page, it does not retype the word.
|
|
expect(history.length).toBe(1);
|
|
|
|
fireEvent.change(domainInput(), { target: { value: "first" } });
|
|
submitFilters();
|
|
await waitFor(() => expect(history.location.search).toContain("domain=first"));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("the field being typed in keeps the focus when the debounce commits", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
const { history } = renderPage();
|
|
await screen.findByText("first.example");
|
|
|
|
const input = domainInput();
|
|
input.focus();
|
|
fireEvent.change(input, { target: { value: "ads" } });
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
});
|
|
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
|
|
|
|
// The same node, still focused, still holding the caret: a toolbar that
|
|
// remounted on the URL it just wrote would drop the next keystroke.
|
|
expect(domainInput()).toBe(input);
|
|
expect(document.activeElement).toBe(input);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("a result segment commits on the click, with no wait and no button", async () => {
|
|
const { history } = renderPage("/activity?domain=ads");
|
|
await screen.findByText("ads.example");
|
|
|
|
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
|
|
|
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
|
|
expect(history.location.search).toContain("domain=ads");
|
|
});
|
|
|
|
test("a time preset writes the second it resolved to, not a rolling window", async () => {
|
|
const now = 1_700_000_000_000;
|
|
vi.spyOn(Date, "now").mockReturnValue(now);
|
|
stubFetch((url) => {
|
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
|
});
|
|
const { history } = renderPage();
|
|
await screen.findByText("No queries logged yet.");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
|
|
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
|
|
|
// Both bounds are concrete, so the link names a closed hour rather than one
|
|
// that keeps growing at the top as the log does.
|
|
const since = now / 1000 - 3600;
|
|
const until = now / 1000;
|
|
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
|
|
expect(history.location.search).toContain(`until=${until}`);
|
|
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
|
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
|
|
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test("Live shows no toolbar at all", async () => {
|
|
vi.stubGlobal(
|
|
"EventSource",
|
|
class {
|
|
constructor(url: string) {
|
|
return new FakeEventSource(url) as unknown as EventSource;
|
|
}
|
|
},
|
|
);
|
|
renderPage("/activity?mode=live&domain=ads");
|
|
await screen.findByText(/the History filters apply to history only/);
|
|
|
|
// The stream is unfiltered, so a control here would promise filtering that is
|
|
// not happening; the filters are still in the URL, waiting for History.
|
|
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
|
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
|
});
|
|
|
|
test("the coverage watermark reads under the results, never over them", 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");
|
|
|
|
const watermark = screen.getByText(/Query history is available from/);
|
|
const count = countLine(/Showing 1 query/);
|
|
// After the count in document order, which is what "footer" means here.
|
|
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|