import test from "node:test"; import assert from "node:assert/strict"; import { loadConfig } from "../src/config.js"; import { NetworkHistory } from "../src/network_history.js"; import { RequestHistory } from "../src/request_history.js"; import { normalizeSourceConfig, validateSourceId } from "../src/source_session_manager.js"; import { sanitizeRequestHeaders, validateFetchUrl } from "../src/security.js"; import { HttpError, type NetworkRecord, type RequestRecord } from "../src/types.js"; import { createDebouncedTask } from "../web/src/debounce.ts"; test("service starts without host allowlist config and defaults to LAN listen address", () => { const config = loadConfig({}); assert.equal(config.host, "0.0.0.0"); assert.equal(config.port, 9230); }); test("CLOAKFETCH_HOST=0.0.0.0 and concrete IPs are accepted", () => { assert.equal(loadConfig({ CLOAKFETCH_HOST: "0.0.0.0" }).host, "0.0.0.0"); assert.equal(loadConfig({ CLOAKFETCH_HOST: "127.0.0.1" }).host, "127.0.0.1"); assert.equal(loadConfig({ CLOAKFETCH_HOST: "::1" }).host, "::1"); }); test("localhost and RFC1918 URLs pass URL format validation", () => { assert.equal(validateFetchUrl("http://localhost:8080/").hostname, "localhost"); assert.equal(validateFetchUrl("http://127.0.0.1:8080/").hostname, "127.0.0.1"); assert.equal(validateFetchUrl("http://192.168.1.10/file").hostname, "192.168.1.10"); assert.equal(validateFetchUrl("http://10.1.2.3/file").hostname, "10.1.2.3"); }); test("URL validation keeps only absolute http and https format checks", () => { assert.throws(() => validateFetchUrl("/relative"), (error) => error instanceof HttpError && error.code === "invalid_url"); assert.throws(() => validateFetchUrl("file:///tmp/a"), (error) => error instanceof HttpError && error.code === "invalid_url_scheme"); }); test("browser-controlled request headers stay forbidden", () => { assert.throws(() => sanitizeRequestHeaders({ Host: "example.com" }), (error) => error instanceof HttpError && error.code === "forbidden_header"); assert.throws(() => sanitizeRequestHeaders({ "Sec-Fetch-Dest": "document" }), (error) => error instanceof HttpError && error.code === "forbidden_header"); }); test("source id rejects path traversal and separators", () => { assert.doesNotThrow(() => validateSourceId("arcgis.default-1")); assert.throws(() => validateSourceId("../x"), (error) => error instanceof HttpError && error.code === "invalid_source_id"); assert.throws(() => validateSourceId("a\\b"), (error) => error instanceof HttpError && error.code === "invalid_source_id"); }); test("source profile defaults under profile/sources/source_id", () => { const config = normalizeSourceConfig({ id: "arcgis" }, "D:\\root\\profile"); assert.equal(config.profile_dir, "D:\\root\\profile\\sources\\arcgis"); }); test("RequestHistory retained stats keep new field names", () => { const history = new RequestHistory(10); history.add(makeRequestRecord({ id: "1", ok: true, status: 200, body_size: 10 })); history.add(makeRequestRecord({ id: "2", ok: false, status: 503, error_code: "http_503" })); const summary = history.summary({ lifetime_total: 99, lifetime_failed: 1, active: 1, queued: 2, }, false, null); assert.equal(summary.retained_total, 2); assert.equal(summary.retained_success, 1); assert.equal(summary.retained_failed, 1); assert.equal((summary as unknown as { total?: number }).total, undefined); }); test("NetworkHistory summary counts retained Chromium records", () => { const history = new NetworkHistory(10); history.upsert(makeNetworkRecord({ id: "n1", method: "GET", resource_type: "document", status: 200, response_body_size: 100, duration_ms: 10, finished_at: new Date().toISOString() })); history.upsert(makeNetworkRecord({ id: "n2", method: "GET", resource_type: "image", status: 304, response_body_size: 0, duration_ms: 4, from_cache: true, finished_at: new Date().toISOString() })); history.upsert(makeNetworkRecord({ id: "n3", method: "POST", resource_type: "fetch", status: null, failure_text: "net::ERR_FAILED", finished_at: new Date().toISOString() })); const summary = history.summary(); assert.equal(summary.retained_total, 3); assert.equal(summary.finished, 3); assert.equal(summary.failed, 1); assert.equal(summary.cache_hit_count, 1); assert.deepEqual(summary.by_resource_type, { document: 1, image: 1, fetch: 1 }); }); test("debounced task collapses multiple schedules into one run", async () => { let runs = 0; const task = createDebouncedTask(() => { runs += 1; }, 20); task.schedule(); task.schedule(); task.schedule(); await new Promise((resolve) => setTimeout(resolve, 70)); assert.equal(runs, 1); task.cancel(); }); function makeRequestRecord(partial: Partial): RequestRecord { return { id: partial.id ?? "id", time: partial.time ?? new Date().toISOString(), kind: partial.kind ?? "fetch", fetch_mode: "document", cache_mode: partial.cache_mode ?? "default", url: partial.url ?? "http://127.0.0.1/file", host: partial.host ?? "127.0.0.1", final_url: partial.final_url === undefined ? "http://127.0.0.1/file" : partial.final_url, status: partial.status === undefined ? 200 : partial.status, ok: partial.ok ?? true, error_code: partial.error_code ?? null, error_message: partial.error_message ?? null, elapsed_ms: partial.elapsed_ms ?? 1, body_size: partial.body_size ?? 0, content_type: partial.content_type ?? "text/plain", proxy_configured: partial.proxy_configured ?? false, proxy_url_masked: partial.proxy_url_masked ?? null, source_id: partial.source_id ?? "default", }; } function makeNetworkRecord(partial: Partial): NetworkRecord { return { id: partial.id ?? "net", source_id: partial.source_id ?? "default", page_id: partial.page_id ?? "page", request_id: partial.request_id ?? partial.id ?? "net", parent_request_id: partial.parent_request_id ?? null, started_at: partial.started_at ?? new Date().toISOString(), finished_at: partial.finished_at ?? null, duration_ms: partial.duration_ms ?? null, url: partial.url ?? "http://127.0.0.1/", method: partial.method ?? "GET", resource_type: partial.resource_type ?? "document", navigation: partial.navigation ?? false, frame_url: partial.frame_url ?? null, initiator: partial.initiator ?? null, request_headers: partial.request_headers ?? {}, request_body_size: partial.request_body_size ?? 0, request_post_data: partial.request_post_data ?? null, status: partial.status ?? null, status_text: partial.status_text ?? null, protocol: partial.protocol ?? null, response_headers: partial.response_headers ?? null, response_body_size: partial.response_body_size ?? 0, response_body: partial.response_body ?? null, mime_type: partial.mime_type ?? null, remote_address: partial.remote_address ?? null, from_cache: partial.from_cache ?? false, service_worker: partial.service_worker ?? false, failure_text: partial.failure_text ?? null, redirect_from: partial.redirect_from ?? null, redirect_to: partial.redirect_to ?? null, }; }