const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); // Smoke coverage for the workstation snapshot surface (issue #13). // // 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. 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 } = {}) { const headers = {}; 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()) } = {}) { return request(route, { method: "POST", auth, body }); } 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"); 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/"], ]; 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"]); }); });