diff --git a/__tests__/hosts.test.js b/__tests__/hosts.test.js index e0486f6..542c076 100644 --- a/__tests__/hosts.test.js +++ b/__tests__/hosts.test.js @@ -3,13 +3,30 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -// Smoke coverage for the workstation snapshot surface (issue #13). +// Coverage matrix for the workstation snapshot surface (issues #13 and #14). // -// This is deliberately NOT the exhaustive matrix — that is issue #14. What is -// pinned here is 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. +// 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"; @@ -51,8 +68,8 @@ function stopServer() { }); } -async function request(route, { method = "GET", auth, body } = {}) { - const headers = {}; +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 }); @@ -60,8 +77,96 @@ async function request(route, { method = "GET", auth, body } = {}) { return { status: res.status, body: parsed }; } -function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()) } = {}) { - return request(route, { method: "POST", auth, body }); +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 () => { @@ -76,6 +181,10 @@ beforeEach(async () => { 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(); }); @@ -98,6 +207,20 @@ describe("POST /hosts/ — routing is the traversal control", () => { ["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) => { @@ -274,4 +397,677 @@ describe("HOSTS_ALLOWED_IDS validation", () => { 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: "