150 lines
7.1 KiB
TypeScript
150 lines
7.1 KiB
TypeScript
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>): 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>): 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,
|
|
};
|
|
}
|