Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
265 lines
7.9 KiB
TypeScript
265 lines
7.9 KiB
TypeScript
/**
|
|
* The fixtures and the stubbed API the configuration tests share.
|
|
*
|
|
* All three pages read the same handful of collections plus
|
|
* `/api/config/status`, so a per-file copy of the stub would be five copies of
|
|
* one contract drifting apart. Test-only: nothing in `src` imports it, so it
|
|
* never reaches the bundle.
|
|
*/
|
|
|
|
import { render, screen } from "@testing-library/react";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
|
import { AuthProvider } from "@/auth/store";
|
|
import { health } from "@/lib/healthFixture";
|
|
import { createQueryClient } from "@/lib/queryClient";
|
|
import { createAppRouter } from "@/routes";
|
|
import type { ConfigStatus, Settings } from "@/lib/types";
|
|
|
|
export const CONFIG_PATH = "/etc/nxdns/config.zon";
|
|
export const RECONCILED_AT = 1754899200;
|
|
|
|
export const DATABASE: ConfigStatus = {
|
|
authority: "database",
|
|
path: null,
|
|
reconciled_at: null,
|
|
restart_pending: false,
|
|
};
|
|
|
|
export const MANAGED_FILE: ConfigStatus = {
|
|
authority: "managed_file",
|
|
path: CONFIG_PATH,
|
|
reconciled_at: RECONCILED_AT,
|
|
restart_pending: false,
|
|
};
|
|
|
|
export function baseSettings(): Settings {
|
|
return {
|
|
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
|
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
|
blocking: { response: "zero", ttl: 300 },
|
|
cache: { size: 10000, negative_ttl_max: 300 },
|
|
web: {
|
|
enabled: true,
|
|
bind: "127.0.0.1",
|
|
port: 8080,
|
|
session_ttl_hours: 24,
|
|
api_rate_limit_per_min: 60,
|
|
api_localhost_exempt: true,
|
|
sse_max_connections_per_ip: 2,
|
|
trusted_proxies: "",
|
|
auth_enabled: true,
|
|
},
|
|
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
|
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
|
edns: { ecs_mode: "strip" },
|
|
logging: {
|
|
level: "info",
|
|
retention_days: 30,
|
|
query_log_buffer_max: 10000,
|
|
query_log_flush_interval_s: 60,
|
|
hide_domains: false,
|
|
hide_client_ips: false,
|
|
output: "stderr",
|
|
file_path: "",
|
|
max_size_mb: 50,
|
|
max_files: 3,
|
|
},
|
|
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
|
blocklist_update: { enabled: true, interval_hours: 24 },
|
|
};
|
|
}
|
|
|
|
export const GROUPS = [
|
|
{ id: 1, name: "default", safe_search: false },
|
|
{ id: 2, name: "kids", safe_search: true },
|
|
];
|
|
|
|
export const BLOCKLISTS = [
|
|
{
|
|
id: 1,
|
|
url: "https://example.com/ads.txt",
|
|
name: "Ads",
|
|
enabled: true,
|
|
is_suggested: false,
|
|
last_updated: null,
|
|
domain_count: 100,
|
|
wildcard_count: 2,
|
|
exception_count: 1,
|
|
skipped_regex_count: 0,
|
|
skipped_unsupported_count: 0,
|
|
checksum: null,
|
|
},
|
|
{
|
|
id: 2,
|
|
url: "https://example.com/trackers.txt",
|
|
name: "Trackers",
|
|
enabled: false,
|
|
is_suggested: true,
|
|
last_updated: 1700000000,
|
|
domain_count: 50,
|
|
wildcard_count: 0,
|
|
exception_count: 0,
|
|
skipped_regex_count: 3,
|
|
skipped_unsupported_count: 4,
|
|
checksum: null,
|
|
},
|
|
];
|
|
|
|
export const RULES = [
|
|
{
|
|
id: 1,
|
|
group_id: 1,
|
|
group: "default",
|
|
pattern: "ads.example.com",
|
|
kind: "exact",
|
|
action: "block",
|
|
created_at: 1700000000,
|
|
},
|
|
{
|
|
id: 2,
|
|
group_id: 2,
|
|
group: "kids",
|
|
pattern: "*.social.example",
|
|
kind: "wildcard",
|
|
action: "block",
|
|
created_at: 1700000100,
|
|
},
|
|
];
|
|
|
|
export const CLIENTS = [
|
|
{
|
|
id: 1,
|
|
ip: "192.168.1.10",
|
|
name: "laptop",
|
|
learned_name: "",
|
|
group_id: 1,
|
|
group: "default",
|
|
hand_edited: true,
|
|
first_seen: 1700000000,
|
|
last_seen: 1700003600,
|
|
},
|
|
{
|
|
id: 2,
|
|
ip: "192.168.1.11",
|
|
name: "",
|
|
learned_name: "tablet.lan",
|
|
group_id: 2,
|
|
group: "kids",
|
|
hand_edited: false,
|
|
first_seen: 1700000000,
|
|
last_seen: 1700007200,
|
|
},
|
|
];
|
|
|
|
export const UPSTREAMS = [
|
|
{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
|
|
{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
|
|
];
|
|
|
|
export const LOCAL_RECORDS = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.5", ttl: 300 }];
|
|
|
|
export const FORWARD_ZONES = [{ id: 1, zone: "lan.home", resolver: "udp://192.168.1.1:53" }];
|
|
|
|
export interface Call {
|
|
url: string;
|
|
method: string;
|
|
body: unknown;
|
|
}
|
|
|
|
export interface StubOptions {
|
|
/**
|
|
* Overrides and additions, keyed `"GET /api/x"`. A `Response` is used
|
|
* verbatim; a function is called per request, so a test can change what the
|
|
* server says between two reads of the same endpoint.
|
|
*/
|
|
responses?: Record<string, unknown>;
|
|
/**
|
|
* First refusal on every non-GET; return null to fall through to the default
|
|
* echo. An unsettled promise holds the mutation in flight, which is how a
|
|
* test observes a pending control.
|
|
*/
|
|
onWrite?: (call: Call) => Response | Promise<Response> | null;
|
|
}
|
|
|
|
function defaultResponses(status: ConfigStatus): Record<string, unknown> {
|
|
return {
|
|
"GET /api/config/status": status,
|
|
"GET /api/health": health(),
|
|
"GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
|
"GET /api/groups": { groups: GROUPS },
|
|
"GET /api/groups/1/sources": { source_ids: [1] },
|
|
"GET /api/groups/2/sources": { source_ids: [] },
|
|
"GET /api/blocklists": { blocklists: BLOCKLISTS },
|
|
"GET /api/rules": { rules: RULES },
|
|
"GET /api/clients": { clients: CLIENTS },
|
|
"GET /api/client-prefixes": { client_prefixes: [] },
|
|
"GET /api/upstreams": { upstreams: UPSTREAMS },
|
|
"GET /api/local-records": { local_records: LOCAL_RECORDS },
|
|
"GET /api/forward-zones": { forward_zones: FORWARD_ZONES },
|
|
"GET /api/settings": { settings: baseSettings(), restart_required: ["dns.port", "web.port"] },
|
|
};
|
|
}
|
|
|
|
function json(payload: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
/** Installs the stub and returns the list every non-GET request is appended to. */
|
|
export function stubApi(status: ConfigStatus = DATABASE, options: StubOptions = {}): Call[] {
|
|
const map = { ...defaultResponses(status), ...options.responses };
|
|
const calls: Call[] = [];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
const method = init?.method ?? "GET";
|
|
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
const key = `${method} ${url}`;
|
|
const configured = map[key];
|
|
// A promise is handed back unsettled on purpose: it is how a test holds a
|
|
// query in its pending state for as long as it needs to.
|
|
if (configured instanceof Promise) return configured;
|
|
if (configured instanceof Response) return configured.clone();
|
|
if (typeof configured === "function") return json((configured as () => unknown)());
|
|
if (method !== "GET") {
|
|
calls.push({ url, method, body });
|
|
const override = options.onWrite?.({ url, method, body });
|
|
if (override !== null && override !== undefined) return override;
|
|
if (configured !== undefined) return json(configured);
|
|
if (method === "DELETE") return new Response(null, { status: 204 });
|
|
return json(body ?? {});
|
|
}
|
|
if (configured === undefined) return json({ error: `not stubbed: ${key}` }, 404);
|
|
return json(configured);
|
|
}),
|
|
);
|
|
return calls;
|
|
}
|
|
|
|
export function renderRoute(route: string) {
|
|
const queryClient = createQueryClient();
|
|
const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
|
|
render(
|
|
<AuthProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>
|
|
</AuthProvider>,
|
|
);
|
|
return router;
|
|
}
|
|
|
|
export async function renderPage(route: string, heading: string) {
|
|
const router = renderRoute(route);
|
|
await screen.findByRole("heading", { name: heading, level: 1 });
|
|
return router;
|
|
}
|
|
|
|
/** The configuration page's own content, excluding the shell chrome around it. */
|
|
export function contentArea(): HTMLElement {
|
|
const main = document.querySelector("main");
|
|
if (main === null) throw new Error("no main element");
|
|
return main;
|
|
}
|