const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); // Coverage matrix for the workstation snapshot surface (issues #13 and #14). // // The first blocks landed with the implementation (#13) and pinned the handful // of decisions that would be expensive to get wrong and silent to regress: the // routing-before-auth ordering, the narrow host id regex as the traversal // control, 401 winning over 403, the whitelist rebuild, and the server-stamped // freshness. #14 grew that smoke pass into the exhaustive matrix that follows — // authentication, allowlist, payload validation, sanitisation, freshness, // listing degradation — because this is the first publicly writable surface on // the API and the only one where a mistake is an intrusion rather than an // outage. // // Two conventions are worth stating once, up front. // // * A malformed host id answers 404, never 403. new URL() normalises // `/hosts/../../etc/passwd` down to `/etc/passwd`, while `..%2f..%2f`, // `THINKPAD` and a 33-character id simply fail HOST_ROUTE_RE — none of them // ever reaches a handler. 403 is reserved for ids that are well formed but // absent from HOSTS_ALLOWED_IDS. If one of these turns red, the fix belongs // in the test: widening the route regex would put raw request input back // onto a file path, which is the very thing the narrow pattern prevents. // * The freshness assertions freeze Date.now() instead of racing the wall // clock, so the 899/900/901 boundary is exact rather than ±1s on a loaded // machine. const TOKEN = "test-token"; const INGEST_TOKEN = "test-ingest-token"; let tmpDir; let hostsDir; let server; let baseUrl; let handler; function validSnapshot() { return { hostname: "thinkpad-x1", uptime: 4242, cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 }, memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 }, disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 }, }; } function startServer() { // Fresh import so module-level constants pick up the stubbed env. delete require.cache[require.resolve("../index.js")]; ({ handler } = require("../index.js")); server = http.createServer(handler); return new Promise((resolve) => { server.listen(0, () => { const { port } = server.address(); baseUrl = `http://127.0.0.1:${port}`; resolve(); }); }); } function stopServer() { return new Promise((resolve) => { if (!server) return resolve(); server.close(() => resolve()); }); } async function request(route, { method = "GET", auth, body, headers: extra } = {}) { const headers = { ...extra }; if (auth) headers.Authorization = auth; if (body !== undefined) headers["Content-Type"] = "application/json"; const res = await fetch(`${baseUrl}${route}`, { method, headers, body }); const parsed = await res.json().catch(() => ({})); return { status: res.status, body: parsed }; } function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()), headers } = {}) { return request(route, { method: "POST", auth, body, headers }); } // --- #14 helpers ------------------------------------------------------------ // MAX_BODY_BYTES / MAX_STRING_LENGTH are private to index.js on purpose; the // tests restate them so a silent change to either constant turns the matrix red // instead of quietly re-drawing the ceiling. const MAX_BODY_BYTES = 4096; const MAX_STRING_LENGTH = 128; // Fixed instant used by every freshness assertion. Snapshots are dated relative // to it and Date.now() is pinned to it for the duration of the request, so // `ageSeconds` is exact rather than "roughly N, unless the machine is busy". const FROZEN_NOW = Date.UTC(2026, 7, 16, 12, 0, 0); async function restartServer() { await stopServer(); await startServer(); } async function restartWithAllowlist(ids) { process.env.HOSTS_ALLOWED_IDS = ids; await restartServer(); } function snapshotPath(id) { return path.join(hostsDir, `${id}.json`); } function snapshotExists(id) { return fs.existsSync(snapshotPath(id)); } function readSnapshotFile(id) { return fs.readFileSync(snapshotPath(id), "utf-8"); } // Write a snapshot straight to disk, bypassing the ingestion path. Used by the // freshness and degradation tests, which need a controlled receivedAt (and, for // the degradation cases, files the API would never have produced itself). function writeRawSnapshot(id, raw) { fs.mkdirSync(hostsDir, { recursive: true }); fs.writeFileSync(snapshotPath(id), raw); } function writeSnapshotAged(id, ageSeconds, overrides = {}) { const record = { id, ...validSnapshot(), receivedAt: new Date(FROZEN_NOW - ageSeconds * 1000).toISOString(), ...overrides, }; writeRawSnapshot(id, JSON.stringify(record)); return record; } // Run a request with Date.now() pinned to FROZEN_NOW. Only Date.now is replaced // (not the whole timer stack), so fetch and the http server keep their real // timeouts; the server reads the clock once, in handleHostsList. async function frozenRequest(route, options = {}) { const realNow = Date.now; Date.now = () => FROZEN_NOW; try { return await request(route, { auth: `Bearer ${TOKEN}`, ...options }); } finally { Date.now = realNow; } } // A JSON body of exactly `bytes` bytes (ASCII only, so bytes === characters), // built around a payload that would otherwise be accepted. Lets the 4 KiB // ceiling be pinned from both sides instead of "somewhere around 4 KiB". function bodyOfExactly(bytes) { const base = { ...validSnapshot(), padding: "" }; const overhead = JSON.stringify(base).length; return JSON.stringify({ ...base, padding: "x".repeat(bytes - overhead) }); } // The 413 is written and the socket is then destroyed, so a client that loses // that race sees a transport error instead of a status. Both outcomes mean the // ceiling fired; what matters is the assertion on the filesystem afterwards. async function ingestOversized(route, body) { try { const { status } = await ingest(route, { body }); return status; } catch { return 413; } } beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-hosts-test-")); hostsDir = path.join(tmpDir, "hosts"); process.env.HEALTH_TOKEN = TOKEN; process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; process.env.HOSTS_DIR = hostsDir; process.env.HOSTS_ALLOWED_IDS = "thinkpad"; delete process.env.HOSTS_STALE_SECONDS; process.env.REPORTS_DIR = path.join(tmpDir, "reports"); process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json"); process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json"); // Closed port: a rejected /health request must never reach the real IdP. If // the auth gate ever fails open, this errors out fast instead of hitting // production (same guard as auth.test.js). process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; await startServer(); }); afterEach(async () => { await stopServer(); fs.rmSync(tmpDir, { recursive: true, force: true }); delete process.env.HOSTS_INGEST_TOKEN; delete process.env.HOSTS_DIR; delete process.env.HOSTS_ALLOWED_IDS; delete process.env.HOSTS_STALE_SECONDS; }); describe("POST /hosts/ — routing is the traversal control", () => { // Every one of these must be 404, not 400/403: the request never reaches a // handler. URL() normalises the traversal away, the rest fail the regex. const REJECTED = [ ["dot-dot traversal", "/hosts/../../etc/passwd"], ["encoded traversal", "/hosts/..%2f..%2fetc%2fpasswd"], ["uppercase id", "/hosts/THINKPAD"], ["id with a slash", "/hosts/thinkpad/extra"], ["empty id", "/hosts/"], // Added by #14 — same rule, wider net. ["fully encoded dots", "/hosts/%2e%2e%2f%2e%2e%2fetc%2fpasswd"], ["trailing slash", "/hosts/thinkpad/"], ["double slash", "/hosts//thinkpad"], ["uppercase path segment", "/HOSTS/thinkpad"], // Percent-encoding is NOT decoded before the match: `%64` stays `%64`, so // an allowlisted id cannot be smuggled in through an encoded spelling. ["percent-encoded allowlisted id", "/hosts/thinkpa%64"], ["null byte", "/hosts/thinkpad%00"], ["file extension", "/hosts/thinkpad.json"], ["leading dash", "/hosts/-thinkpad"], ["underscore", "/hosts/think_pad"], ["dot in the id", "/hosts/think.pad"], ["absolute path as id", "/hosts//etc/passwd"], ]; test.each(REJECTED)("404 on POST %s", async (_label, route) => { const { status, body } = await ingest(route); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); test("404 on GET /hosts/thinkpad — the id route is POST-only", async () => { const { status } = await request("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(404); }); }); describe("POST /hosts/ — auth gate", () => { test("401 with no Authorization header", async () => { const { status, body } = await ingest("/hosts/thinkpad", { auth: null }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); }); test("401 when presenting the read token instead of the ingest token", async () => { const { status } = await ingest("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(401); }); // 401 must win over 403 so an unauthenticated caller cannot probe the // allowlist for valid workstation ids. test("401 (not 403) on a non-allowlisted id without a token", async () => { const { status } = await ingest("/hosts/popos", { auth: null }); expect(status).toBe(401); }); test("403 on a well-formed id outside the allowlist", async () => { const { status, body } = await ingest("/hosts/popos"); expect(status).toBe(403); expect(body.error).toBe("Forbidden"); }); test("503 when HOSTS_INGEST_TOKEN is unset (fail-closed)", async () => { delete process.env.HOSTS_INGEST_TOKEN; await stopServer(); await startServer(); const { status, body } = await ingest("/hosts/thinkpad"); expect(status).toBe(503); expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured"); }); }); describe("POST /hosts/ — body handling", () => { test("204 and an atomically written file on a valid snapshot", async () => { const { status } = await ingest("/hosts/thinkpad"); expect(status).toBe(204); const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8")); expect(written.hostname).toBe("thinkpad-x1"); expect(typeof written.receivedAt).toBe("string"); // No leftover temp file next to the snapshot. expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]); }); test("the persisted object is rebuilt from the whitelist, not stored verbatim", async () => { const raw = `{"__proto__":{"polluted":true},"extra":"nope","receivedAt":"1999-01-01T00:00:00.000Z",${JSON.stringify( { ...validSnapshot(), hostname: "h".repeat(300) }, ).slice(1)}`; const { status } = await ingest("/hosts/thinkpad", { body: raw }); expect(status).toBe(204); const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8")); expect(Object.keys(written)).toEqual([ "id", "hostname", "uptime", "cpu", "memory", "disk", "receivedAt", ]); expect(written.extra).toBeUndefined(); expect(written.polluted).toBeUndefined(); expect({}.polluted).toBeUndefined(); // Strings capped at 128 chars, timestamp stamped by the server. expect(written.hostname.length).toBe(128); expect(written.receivedAt).not.toBe("1999-01-01T00:00:00.000Z"); expect(Date.now() - new Date(written.receivedAt).getTime()).toBeLessThan(10000); }); test("400 on unparsable JSON", async () => { const { status } = await ingest("/hosts/thinkpad", { body: "{not json" }); expect(status).toBe(400); }); test("400 on a missing or mistyped field", async () => { const payload = validSnapshot(); delete payload.memory; const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }); expect(status).toBe(400); expect(body.error).toMatch(/memory/); expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false); }); test("413 past 4 KiB, and nothing is written", async () => { const payload = { ...validSnapshot(), padding: "x".repeat(5000) }; let status; try { ({ status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) })); } catch { // The socket is destroyed right after the 413 flushes; a client that // loses the race sees a transport error instead. Either way the write // must not have happened, which is what the assertion below pins. status = 413; } expect(status).toBe(413); expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false); }); }); describe("GET /hosts", () => { test("401 when presenting the ingest token instead of the read token", async () => { const { status } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` }); expect(status).toBe(401); }); test("reports an allowlisted host that never checked in", async () => { const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(200); expect(body.staleAfterSeconds).toBe(900); expect(body.hosts).toHaveLength(1); expect(body.hosts[0]).toMatchObject({ id: "thinkpad", neverSeen: true, online: false }); }); test("serves the snapshot back with a server-computed freshness", async () => { await ingest("/hosts/thinkpad"); const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(200); const host = body.hosts[0]; expect(host.neverSeen).toBeUndefined(); expect(host.hostname).toBe("thinkpad-x1"); expect(host.ageSeconds).toBeLessThan(5); expect(host.online).toBe(true); expect(Object.keys(host.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]); }); test("online flips to false past HOSTS_STALE_SECONDS", async () => { fs.mkdirSync(hostsDir, { recursive: true }); const stale = { id: "thinkpad", ...validSnapshot(), receivedAt: new Date(Date.now() - 3600 * 1000).toISOString(), }; fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), JSON.stringify(stale)); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.hosts[0].online).toBe(false); expect(body.hosts[0].ageSeconds).toBeGreaterThan(3000); }); test("degrades a corrupted snapshot instead of failing the whole response", async () => { fs.mkdirSync(hostsDir, { recursive: true }); fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), "{ truncated"); const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(200); expect(body.hosts).toHaveLength(1); expect(body.hosts[0].neverSeen).toBe(true); }); }); describe("HOSTS_ALLOWED_IDS validation", () => { test("entries that are not valid host ids are dropped at startup", async () => { process.env.HOSTS_ALLOWED_IDS = "thinkpad, ../defenseurs/status ,POPOS,,popos"; await stopServer(); await startServer(); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]); }); test("an unset allowlist falls back to the single default id", async () => { await restartWithAllowlist(""); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.hosts.map((h) => h.id)).toEqual(["thinkpad"]); }); test("an allowlist of only invalid entries admits nobody", async () => { await restartWithAllowlist(", ,POPOS,../etc"); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.hosts).toEqual([]); // And the ingestion path closes with it: nothing is allowlisted, so a // well-formed id is now 403 rather than accepted by default. const { status } = await ingest("/hosts/thinkpad"); expect(status).toBe(403); }); test("a repeated id yields a single entry", async () => { await restartWithAllowlist("thinkpad,thinkpad, thinkpad "); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.hosts.map((h) => h.id)).toEqual(["thinkpad"]); }); }); // ============================================================================ // Issue #14 — exhaustive matrix // ============================================================================ describe("POST /hosts/ — the ingestion token, near-miss by near-miss", () => { const BAD_AUTH = [ ["a wrong token", "Bearer wrong-ingest-token"], ["the scheme with no token", "Bearer"], ["a lowercase scheme", `bearer ${INGEST_TOKEN}`], ["an uppercase scheme", `BEARER ${INGEST_TOKEN}`], ["the raw token with no scheme", INGEST_TOKEN], ["a Basic scheme carrying the token", `Basic ${Buffer.from(`x:${INGEST_TOKEN}`).toString("base64")}`], ["an extra space between scheme and token", `Bearer ${INGEST_TOKEN}`], ["a prefix of the real token", `Bearer ${INGEST_TOKEN.slice(0, -1)}`], ["the real token with one character appended", `Bearer ${INGEST_TOKEN}x`], ]; test.each(BAD_AUTH)("401 with %s", async (_label, header) => { const { status, body } = await ingest("/hosts/thinkpad", { auth: header }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); expect(snapshotExists("thinkpad")).toBe(false); }); }); describe("POST /hosts/ — fail-closed when HOSTS_INGEST_TOKEN is unset", () => { beforeEach(async () => { delete process.env.HOSTS_INGEST_TOKEN; await restartServer(); }); test("503 with no Authorization header at all", async () => { const { status, body } = await ingest("/hosts/thinkpad", { auth: null }); expect(status).toBe(503); expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured"); }); // Auth runs before the allowlist check, so a misconfigured deployment reports // its own fault rather than blaming the caller's id. test("503 wins over the 403 a non-allowlisted id would otherwise get", async () => { const { status, body } = await ingest("/hosts/popos"); expect(status).toBe(503); expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured"); }); test("404 still wins for a malformed id — routing runs before auth", async () => { const { status, body } = await ingest("/hosts/THINKPAD"); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); test("the read routes are unaffected — the two tokens are independent", async () => { const { status } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(200); }); }); describe("the read token and the ingestion token are independent", () => { test("ingestion still works when HEALTH_TOKEN is unset", async () => { delete process.env.HEALTH_TOKEN; await restartServer(); const { status } = await ingest("/hosts/thinkpad"); expect(status).toBe(204); }); test("the read routes fail closed when HEALTH_TOKEN is unset", async () => { delete process.env.HEALTH_TOKEN; await restartServer(); const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(401); expect(body.error).toBe("HEALTH_TOKEN not configured"); }); }); describe("the ingestion token opens nothing on the read side", () => { // The write token lives on every workstation, i.e. on the least trusted // machines in the parc. It must not read the VPS metrics or the Defenseurs // reports back. const READ_ROUTES = [ "/health", "/defenseurs", "/defenseurs/findings", "/reports/scans", "/hosts", ]; test.each(READ_ROUTES)("401 on GET %s with the ingest token", async (route) => { const { status, body } = await request(route, { auth: `Bearer ${INGEST_TOKEN}` }); expect(status).toBe(401); expect(body.error).toBe("Unauthorized"); }); }); describe("POST /hosts/ — route boundaries", () => { test("a 32-character id is well formed: 403, not 404", async () => { const { status, body } = await ingest(`/hosts/${"a".repeat(32)}`); expect(status).toBe(403); expect(body.error).toBe("Forbidden"); }); test("a 33-character id fails the route: 404, not 403", async () => { const { status, body } = await ingest(`/hosts/${"a".repeat(33)}`); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); test.each(["GET", "PUT", "PATCH", "DELETE"])("404 on %s /hosts/thinkpad", async (method) => { const { status } = await request("/hosts/thinkpad", { method, auth: `Bearer ${INGEST_TOKEN}` }); expect(status).toBe(404); }); test("404 on POST /hosts — the collection is read-only", async () => { const { status, body } = await ingest("/hosts"); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); test("a query string is not part of the id", async () => { const { status } = await ingest("/hosts/thinkpad?debug=1"); expect(status).toBe(204); expect(snapshotExists("thinkpad")).toBe(true); }); // 404 leaks nothing about the token or the allowlist: a malformed id answers // the same whether the caller is authenticated, wrong, or silent. const UNAUTH = [ ["no Authorization header", null], ["a wrong token", "Bearer wrong"], ["the read token", `Bearer ${TOKEN}`], ["the valid ingest token", `Bearer ${INGEST_TOKEN}`], ]; test.each(UNAUTH)("404 on a malformed id with %s", async (_label, auth) => { const { status, body } = await ingest("/hosts/../../etc/passwd", { auth }); expect(status).toBe(404); expect(body.error).toBe("Not found"); }); }); describe("rejections leave an audit trail", () => { let warn; beforeEach(() => { warn = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warn.mockRestore(); }); function lastLine() { return warn.mock.calls.at(-1)[0]; } test("a 401 on the ingestion path is logged with its route", async () => { await ingest("/hosts/thinkpad", { auth: "Bearer wrong" }); const line = lastLine(); expect(line).toContain("[auth] 401 POST /hosts/thinkpad"); expect(line).toContain("id=thinkpad"); expect(line).toContain("reason=missing or invalid bearer token"); }); test("a 403 names the id that was refused", async () => { await ingest("/hosts/popos"); const line = lastLine(); expect(line).toContain("[auth] 403 POST /hosts/popos"); expect(line).toContain("id=popos"); }); test("a 404 is not logged — it never reached the auth gate", async () => { await ingest("/hosts/THINKPAD"); expect(warn).not.toHaveBeenCalled(); }); // The log line is assembled from caller-controlled values (X-Real-IP among // them), so it must stay on one line and inside printable ASCII: a crafted // header must not be able to forge an extra log record. test("a hostile X-Real-IP cannot break out of its log line", async () => { await ingest("/hosts/thinkpad", { auth: "Bearer wrong", headers: { "X-Real-IP": `1.2.3.4éé${"A".repeat(200)}` }, }); const line = lastLine(); expect(line).not.toMatch(/[\r\n]/); expect(line).toMatch(/^[\x20-\x7e]+$/); const ip = /ip=(\S+) /.exec(line)[1]; expect(ip.length).toBeLessThanOrEqual(64); }); }); describe("POST /hosts/ — bodies that are not a JSON object", () => { const INVALID_BODIES = [ ["unparsable JSON", "{not json", /valid JSON/], ["an empty body", "", /valid JSON/], ["a truncated object", '{"hostname":"x"', /valid JSON/], ["a JSON array", "[]", /body must be a JSON object/], ["JSON null", "null", /body must be a JSON object/], ["a bare JSON string", '"thinkpad"', /body must be a JSON object/], ["a bare JSON number", "42", /body must be a JSON object/], ["a bare JSON boolean", "true", /body must be a JSON object/], ]; test.each(INVALID_BODIES)("400 on %s", async (_label, body, expected) => { const { status, body: parsed } = await ingest("/hosts/thinkpad", { body }); expect(status).toBe(400); expect(parsed.error).toMatch(expected); expect(snapshotExists("thinkpad")).toBe(false); }); }); describe("POST /hosts/ — field validation matrix", () => { // Each row mutates an otherwise valid snapshot. The expected fragment pins // that the error names the offending field: the workstation agent is // debugged from this message alone. const INVALID_FIELDS = [ ["hostname missing", (p) => delete p.hostname, /hostname must be a string/], ["hostname as a number", (p) => (p.hostname = 42), /hostname must be a string/], ["hostname as an object", (p) => (p.hostname = { name: "x" }), /hostname must be a string/], ["hostname as null", (p) => (p.hostname = null), /hostname must be a string/], ["uptime missing", (p) => delete p.uptime, /uptime must be a finite number/], ["uptime as a numeric string", (p) => (p.uptime = "4242"), /uptime must be a finite number/], ["cpu missing", (p) => delete p.cpu, /cpu must be an object/], ["cpu as an array", (p) => (p.cpu = []), /cpu must be an object/], ["cpu as null", (p) => (p.cpu = null), /cpu must be an object/], ["cpu.model missing", (p) => delete p.cpu.model, /cpu\.model must be a string/], ["cpu.cores as a string", (p) => (p.cpu.cores = "8"), /cpu\.cores must be a finite number/], ["cpu.usagePercent missing", (p) => delete p.cpu.usagePercent, /cpu\.usagePercent must be a finite number/], ["cpu.loadAvg missing", (p) => delete p.cpu.loadAvg, /cpu\.loadAvg must be an array/], ["cpu.loadAvg as a string", (p) => (p.cpu.loadAvg = "0.5"), /cpu\.loadAvg must be an array/], ["cpu.loadAvg holding a string", (p) => (p.cpu.loadAvg = [0.5, "x", 0.3]), /cpu\.loadAvg must contain finite numbers/], ["memory missing", (p) => delete p.memory, /memory must be an object/], ["memory as an array", (p) => (p.memory = []), /memory must be an object/], ["memory.usedGB missing", (p) => delete p.memory.usedGB, /memory\.usedGB must be a finite number/], ["memory.usagePercent as a string", (p) => (p.memory.usagePercent = "39"), /memory\.usagePercent must be a finite number/], ["disk missing", (p) => delete p.disk, /disk must be an object/], ["disk.totalGB missing", (p) => delete p.disk.totalGB, /disk\.totalGB must be a finite number/], ["disk.freeGB as null", (p) => (p.disk.freeGB = null), /disk\.freeGB must be a finite number/], ]; test.each(INVALID_FIELDS)("400 on %s", async (_label, mutate, expected) => { const payload = validSnapshot(); mutate(payload); const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }); expect(status).toBe(400); expect(body.error).toMatch(expected); expect(snapshotExists("thinkpad")).toBe(false); }); // JSON.stringify() cannot even express these, so they need a raw body: a // hand-rolled agent (or a hostile one) can put them on the wire. test("400 on an overflowing number that JSON.parse turns into Infinity", async () => { const raw = JSON.stringify(validSnapshot()).replace('"uptime":4242', '"uptime":1e999'); const { status, body } = await ingest("/hosts/thinkpad", { body: raw }); expect(status).toBe(400); expect(body.error).toMatch(/uptime must be a finite number/); expect(snapshotExists("thinkpad")).toBe(false); }); test("a duplicate key keeps the last value and is still validated", async () => { const raw = `{"hostname":"first",${JSON.stringify({ ...validSnapshot(), hostname: 42 }).slice(1)}`; const { status, body } = await ingest("/hosts/thinkpad", { body: raw }); expect(status).toBe(400); expect(body.error).toMatch(/hostname must be a string/); }); test("uptime of 0 is a legitimate value, not a missing field", async () => { const { status } = await ingest("/hosts/thinkpad", { body: JSON.stringify({ ...validSnapshot(), uptime: 0 }), }); expect(status).toBe(204); expect(JSON.parse(readSnapshotFile("thinkpad")).uptime).toBe(0); }); test("an empty loadAvg array is accepted", async () => { const payload = validSnapshot(); payload.cpu.loadAvg = []; const { status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }); expect(status).toBe(204); expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([]); }); }); describe("POST /hosts/ — the 4 KiB ceiling", () => { test("an 8 KiB body is refused and nothing is written", async () => { const body = JSON.stringify({ ...validSnapshot(), padding: "x".repeat(8192) }); expect(await ingestOversized("/hosts/thinkpad", body)).toBe(413); expect(snapshotExists("thinkpad")).toBe(false); }); test("a body of exactly 4096 bytes is accepted", async () => { const body = bodyOfExactly(MAX_BODY_BYTES); expect(Buffer.byteLength(body)).toBe(MAX_BODY_BYTES); const { status } = await ingest("/hosts/thinkpad", { body }); expect(status).toBe(204); // The padding was only there to reach the ceiling — it is not a whitelisted // field, so it must not survive into the file. expect(readSnapshotFile("thinkpad")).not.toContain("padding"); }); test("one byte past the ceiling is refused", async () => { expect(await ingestOversized("/hosts/thinkpad", bodyOfExactly(MAX_BODY_BYTES + 1))).toBe(413); expect(snapshotExists("thinkpad")).toBe(false); }); // The literal reading of the acceptance criterion: a 4 KiB hostname never // even reaches the sanitiser, because the body carrying it is already over // the ceiling. The truncation control is pinned separately below, with the // largest hostname that does fit. test("a 4 KiB hostname is stopped by the ceiling, not by the sanitiser", async () => { const body = JSON.stringify({ ...validSnapshot(), hostname: "H".repeat(4096) }); expect(await ingestOversized("/hosts/thinkpad", body)).toBe(413); expect(snapshotExists("thinkpad")).toBe(false); }); }); describe("POST /hosts/ — what actually lands on disk", () => { test("a hostname that fits under the ceiling is truncated to 128 characters", async () => { const body = JSON.stringify({ ...validSnapshot(), hostname: "H".repeat(3500) }); const { status } = await ingest("/hosts/thinkpad", { body }); expect(status).toBe(204); const raw = readSnapshotFile("thinkpad"); expect(JSON.parse(raw).hostname).toBe("H".repeat(MAX_STRING_LENGTH)); // Not merely "shorter": the oversized value is nowhere in the file. expect(raw).not.toContain("H".repeat(MAX_STRING_LENGTH + 1)); }); test("cpu.model is capped the same way", async () => { const payload = validSnapshot(); payload.cpu.model = "M".repeat(1000); await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }); expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.model.length).toBe(MAX_STRING_LENGTH); }); test("loadAvg keeps only the first three entries", async () => { const payload = validSnapshot(); payload.cpu.loadAvg = [1, 2, 3, 4, 5]; await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }); expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([1, 2, 3]); }); test("__proto__ appears nowhere in the file text", async () => { const raw = `{"__proto__":{"polluted":true},${JSON.stringify(validSnapshot()).slice(1)}`; const { status } = await ingest("/hosts/thinkpad", { body: raw }); expect(status).toBe(204); expect(readSnapshotFile("thinkpad")).not.toContain("__proto__"); expect({}.polluted).toBeUndefined(); }); test("constructor and prototype keys are dropped like any other unknown key", async () => { const raw = `{"constructor":{"x":1},"prototype":{"y":2},${JSON.stringify(validSnapshot()).slice(1)}`; const { status } = await ingest("/hosts/thinkpad", { body: raw }); expect(status).toBe(204); const written = readSnapshotFile("thinkpad"); expect(written).not.toContain("prototype"); expect(written).not.toContain('"constructor"'); }); test("the route id wins over an id supplied in the body", async () => { const body = JSON.stringify({ ...validSnapshot(), id: "popos" }); await ingest("/hosts/thinkpad", { body }); expect(JSON.parse(readSnapshotFile("thinkpad")).id).toBe("thinkpad"); expect(snapshotExists("popos")).toBe(false); }); test("the snapshot file is created readable by its owner only", async () => { await ingest("/hosts/thinkpad"); expect(fs.statSync(snapshotPath("thinkpad")).mode & 0o777).toBe(0o600); }); test("HOSTS_DIR is created on demand, nested levels included", async () => { hostsDir = path.join(tmpDir, "deep", "nested", "hosts"); process.env.HOSTS_DIR = hostsDir; await restartServer(); const { status } = await ingest("/hosts/thinkpad"); expect(status).toBe(204); expect(snapshotExists("thinkpad")).toBe(true); }); test("a second POST replaces the first, leaving one file and no temp debris", async () => { await ingest("/hosts/thinkpad"); const first = JSON.parse(readSnapshotFile("thinkpad")); await ingest("/hosts/thinkpad", { body: JSON.stringify({ ...validSnapshot(), hostname: "thinkpad-x1-reinstalled" }), }); const second = JSON.parse(readSnapshotFile("thinkpad")); expect(second.hostname).toBe("thinkpad-x1-reinstalled"); expect(new Date(second.receivedAt).getTime()).toBeGreaterThanOrEqual( new Date(first.receivedAt).getTime(), ); expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]); }); test("two workstations write to two files", async () => { await restartWithAllowlist("thinkpad,popos"); await ingest("/hosts/thinkpad"); await ingest("/hosts/popos", { body: JSON.stringify({ ...validSnapshot(), hostname: "popos-desktop" }), }); expect(fs.readdirSync(hostsDir).sort()).toEqual(["popos.json", "thinkpad.json"]); expect(JSON.parse(readSnapshotFile("popos")).hostname).toBe("popos-desktop"); expect(JSON.parse(readSnapshotFile("thinkpad")).hostname).toBe("thinkpad-x1"); }); // The API stores what it was given, within the cap. Escaping belongs to the // consumer (the admin dashboard renders through React, which escapes); the // point here is that the stored value is not silently mangled either. test("markup in a hostname is stored verbatim, not escaped or stripped", async () => { const hostile = ""; await ingest("/hosts/thinkpad", { body: JSON.stringify({ ...validSnapshot(), hostname: hostile }), }); expect(JSON.parse(readSnapshotFile("thinkpad")).hostname).toBe(hostile); }); test("500 when the snapshot cannot be persisted", async () => { // HOSTS_DIR points at a regular file: mkdirSync throws, the handler must // answer 500 rather than crash the process. const blocker = path.join(tmpDir, "not-a-directory"); fs.writeFileSync(blocker, ""); process.env.HOSTS_DIR = blocker; await restartServer(); const { status, body } = await ingest("/hosts/thinkpad"); expect(status).toBe(500); expect(body.error).toBe("Internal error"); }); }); describe("GET /hosts — freshness boundary", () => { const BOUNDARY = [ [0, true], [1, true], [899, true], [900, true], [901, false], [3600, false], ]; test.each(BOUNDARY)("an age of %ss reports online=%s", async (ageSeconds, online) => { writeSnapshotAged("thinkpad", ageSeconds); const { status, body } = await frozenRequest("/hosts"); expect(status).toBe(200); expect(body.hosts[0].ageSeconds).toBe(ageSeconds); expect(body.hosts[0].online).toBe(online); }); // A workstation whose clock runs ahead cannot report a negative age, and it // gains nothing by trying: receivedAt is stamped by the server anyway. test("a receivedAt in the future clamps to an age of 0", async () => { writeSnapshotAged("thinkpad", -600); const { body } = await frozenRequest("/hosts"); expect(body.hosts[0].ageSeconds).toBe(0); expect(body.hosts[0].online).toBe(true); }); test("ageSeconds is derived from receivedAt, which is echoed unchanged", async () => { const record = writeSnapshotAged("thinkpad", 42); const { body } = await frozenRequest("/hosts"); expect(body.hosts[0].receivedAt).toBe(record.receivedAt); expect(body.hosts[0].ageSeconds).toBe(42); }); }); describe("HOSTS_STALE_SECONDS", () => { test("a custom threshold moves the boundary and is echoed to the client", async () => { process.env.HOSTS_STALE_SECONDS = "60"; await restartServer(); writeSnapshotAged("thinkpad", 60); const { body } = await frozenRequest("/hosts"); expect(body.staleAfterSeconds).toBe(60); expect(body.hosts[0].online).toBe(true); }); test("one second past a custom threshold is offline", async () => { process.env.HOSTS_STALE_SECONDS = "60"; await restartServer(); writeSnapshotAged("thinkpad", 61); const { body } = await frozenRequest("/hosts"); expect(body.hosts[0].online).toBe(false); }); const BAD_THRESHOLDS = [ ["unset", undefined], ["empty", ""], ["not a number", "abc"], ["zero", "0"], ["negative", "-5"], ]; test.each(BAD_THRESHOLDS)("falls back to 900 when %s", async (_label, value) => { if (value === undefined) delete process.env.HOSTS_STALE_SECONDS; else process.env.HOSTS_STALE_SECONDS = value; await restartServer(); const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.staleAfterSeconds).toBe(900); }); }); describe("GET /hosts — listing semantics", () => { test("one entry per allowlisted id, sorted, whatever their state", async () => { await restartWithAllowlist("thinkpad,popos,pixel"); writeSnapshotAged("thinkpad", 10); writeRawSnapshot("popos", "{ truncated"); const { body } = await frozenRequest("/hosts"); expect(body.hosts.map((h) => h.id)).toEqual(["pixel", "popos", "thinkpad"]); // pixel never checked in, popos is corrupt — neither takes the healthy // neighbour down with it. expect(body.hosts[0].neverSeen).toBe(true); expect(body.hosts[1].neverSeen).toBe(true); expect(body.hosts[2]).toMatchObject({ hostname: "thinkpad-x1", ageSeconds: 10, online: true, }); }); test("a snapshot file for a non-allowlisted id is never served", async () => { writeSnapshotAged("popos", 10); const { body } = await frozenRequest("/hosts"); expect(body.hosts.map((h) => h.id)).toEqual(["thinkpad"]); expect(JSON.stringify(body)).not.toContain("popos"); }); test("a never-seen entry has an exact, stable shape", async () => { const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(body.hosts[0]).toEqual({ id: "thinkpad", receivedAt: null, ageSeconds: null, online: false, neverSeen: true, }); }); test("a live entry has an exact, stable key set", async () => { writeSnapshotAged("thinkpad", 5); const { body } = await frozenRequest("/hosts"); expect(Object.keys(body.hosts[0])).toEqual([ "id", "hostname", "uptime", "cpu", "memory", "disk", "receivedAt", "ageSeconds", "online", ]); }); // HOSTS_DIR is a writable bind-mount, so the file is re-validated on the way // out rather than trusted because "we wrote it". const DEGRADED = [ ["an unparsable file", "{ truncated"], ["an empty file", ""], ["a JSON array", "[]"], ["JSON null", "null"], ["an off-shape snapshot", JSON.stringify({ id: "thinkpad", hostname: "x" })], ["a snapshot with no receivedAt", JSON.stringify({ id: "thinkpad", ...validSnapshot() })], [ "a receivedAt that is not a date", JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "yesterday" }), ], [ "a receivedAt that is not a string", JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: 1700000000000 }), ], ]; test.each(DEGRADED)("degrades %s to a never-seen entry", async (_label, raw) => { writeRawSnapshot("thinkpad", raw); const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); expect(status).toBe(200); expect(body.hosts).toHaveLength(1); expect(body.hosts[0].neverSeen).toBe(true); expect(body.hosts[0].online).toBe(false); }); test("keys added to the file on disk are stripped on the way out", async () => { writeSnapshotAged("thinkpad", 10, { evil: "