const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const child_process = require("node:child_process"); const { fileURLToPath } = require("node:url"); // The API's write surfaces are HOSTS_DIR (issue #36) and, since #48, the // directory of CLAUDE_USAGE_PATH, written by POST /claude-usage only. Every // case below allows HOSTS_DIR alone, except the POST /claude-usage one, which // allows its own directory alone. // // Since 2026-09-30 the two Defenseurs mounts are read-only in the container // (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR // or /data/defenseurs would not fail in the other test files: their fixtures // live in a writable temp dir, and CI runs as root, so a chmod-555 fixture // would prove nothing either. It would fail in production, as a 500 (EROFS) // on the route that tried it. This file moves that failure to CI, for the // paths it exercises: boot, the nominal reads and ingest, and the degraded // ones (rejections, invalid input, a corrupt file, missing files). // // Every node:fs function that can write is spied BEFORE a fresh require, // because the runtime modules destructure their fs functions at load time // (same technique as the timingSafeEqual spy in auth.test.js). Every call is // recorded, whoever makes it, then passed through to the real function. No // attribution by call stack (#40): a function handed as-is to an asynchronous // mechanism, `process.nextTick(writeFileSync, file, data)` or // `res.on("finish", writeFileSync.bind(null, file, data))`, runs with no // runtime frame on its stack, and a stack filter let it through. The price is // that a write made by the test runner itself while a case runs would fail the // case: the right way to fail. A shell command can write too, out of the fs // spies' sight, so child_process is spied the same way and the set of commands // is pinned to the two read-only ones metrics.js runs. // // The test edits its own fixtures in the degraded case. Those edits go through // `real`, the functions taken before any spy exists, so the spies never have // to look away. // // Limits: a path no case takes stays out of sight, and so does a write // deferred past settle() (a timer longer than 20 ms). // // Boot is exercised by calling the exported sweepStaleTempFiles(), which is // what the `require.main === module` block runs before listen. That block // never runs in-process, so its content is pinned at the end of this file. const REPO_ROOT = path.resolve(__dirname, ".."); const TOKEN = "test-token"; const INGEST_TOKEN = "test-ingest-token"; const CLAUDE_TOKEN = "test-claude-token"; const ORPHAN = ".thinkpad.4242.1700000000000.tmp"; // The real functions, taken at load time, before any spy: the test's own // fixture edits go through them and stay out of the logs. Only single-call // functions belong here. `rmSync(dir, { recursive: true })` would not do: it // calls the public fs.rmdirSync internally, which is the spy by then. const real = { writeFileSync: fs.writeFileSync, rmSync: fs.rmSync, renameSync: fs.renameSync }; // Runtime file = under the repo root, outside node_modules and __tests__. // Used to purge the module cache. function isRuntimeFile(file) { const rel = path.relative(REPO_ROOT, file); if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false; const top = rel.split(path.sep)[0]; return top !== "node_modules" && top !== "__tests__"; } // Write APIs of node:fs, by the argument(s) naming what gets written. Each is // spied in its callback, Sync and fs.promises forms, when that form exists. const O_WRITE = fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND; function opensForWriting(flags) { if (flags === undefined || flags === null || typeof flags === "function") return false; // "r" if (typeof flags === "number") return (flags & O_WRITE) !== 0; return /[wa+]/.test(String(flags)); } // mkdtemp's argument is a prefix: `/x` creates `/xXXXXXX` below // , but `` alone creates the sibling `XXXXXX`. Checking the // prefix plus one character tells the two apart. function mkdtempTarget(prefix) { if (prefix instanceof URL) return `${fileURLToPath(prefix)}X`; if (typeof prefix === "string" || Buffer.isBuffer(prefix)) return `${prefix.toString()}X`; return prefix; } const at = (...indexes) => (args) => indexes.map((i) => args[i]); const WRITE_APIS = [ // The destination is the first argument. ...[ "writeFile", "appendFile", "unlink", "mkdir", "rm", "rmdir", "truncate", "chmod", "lchmod", "chown", "lchown", "utimes", "lutimes", "createWriteStream", ].map((name) => [name, at(0)]), ["mkdtemp", (args) => [mkdtempTarget(args[0])]], // Only the destination (second argument) is written. ...["copyFile", "cp", "link", "symlink"].map((name) => [name, at(1)]), // Both ends of a rename change. ["rename", at(0, 1)], // Metadata changes through a file descriptor: the target cannot be proven, // so the fd itself is recorded and fails the path rule. ...["fchmod", "fchown", "futimes"].map((name) => [name, at(0)]), // An open is a write only when its flags say so. ["open", (args) => (opensForWriting(args[1]) ? [args[0]] : null)], ]; // The two commands metrics.js runs, to read /proc/stat and the root disk. const ALLOWED_COMMANDS = ["df -k /", "head -1 /proc/stat"]; const CHILD_PROCESS_APIS = ["exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "fork"]; // `exec("cmd")` and `execFile("file", ["arg"])` both come out as one string. function commandOf(args) { return (Array.isArray(args[1]) ? [args[0], ...args[1]] : [args[0]]).map(String).join(" "); } let log = []; let commandLog = []; function drain() { const out = log; log = []; return out; } function foreignCommands() { return commandLog.filter((command) => !ALLOWED_COMMANDS.includes(command)); } function installWriteSpies() { for (const [name, targetsOf] of WRITE_APIS) { for (const [owner, key] of [ [fs, name], [fs, `${name}Sync`], [fs.promises, name], ]) { // lchmod is an own property holding undefined on Linux. if (typeof owner[key] !== "function") continue; const original = owner[key]; vi.spyOn(owner, key).mockImplementation(function (...args) { const targets = targetsOf(args); if (targets) log.push({ fn: key, targets }); return original.apply(this, args); }); } } } function installCommandSpies() { for (const key of CHILD_PROCESS_APIS) { if (typeof child_process[key] !== "function") continue; const original = child_process[key]; vi.spyOn(child_process, key).mockImplementation(function (...args) { commandLog.push(commandOf(args)); return original.apply(this, args); }); } } // Every runtime module, not only index.js: a module left in the cache would // keep the functions it destructured under a previous test's spies, which // vi.restoreAllMocks() has turned back into silent pass-throughs. function purgeRuntimeModules() { for (const file of Object.keys(require.cache)) { if (isRuntimeFile(file)) delete require.cache[file]; } } // Equal to the allowed directory or below it — HOSTS_DIR unless the case names // another one (only POST /claude-usage does, for its own directory, #48). // path.relative rather than startsWith, so a sibling such as `/hosts-evil` // does not pass. A file descriptor, or any argument that is not a path, cannot // be proven and fails. function toPath(arg) { if (typeof arg === "string") return path.resolve(arg); if (Buffer.isBuffer(arg)) return path.resolve(arg.toString()); if (arg instanceof URL) return path.resolve(fileURLToPath(arg)); return null; } function violations(writes, allowedDir = process.env.HOSTS_DIR) { const hostsDir = path.resolve(allowedDir); return writes.flatMap(({ fn, targets }) => targets .filter((arg) => { const target = toPath(arg); if (target === null) return true; const rel = path.relative(hostsDir, target); return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel); }) .map((arg) => `${fn}(${typeof arg === "string" ? arg : typeof arg})`), ); } 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 validClaudeUsage() { return { schemaVersion: 1, timeZone: "America/Toronto", limits: { status: "ok", fetchedAt: "2026-09-30T07:30:00.000Z", items: [] }, // Two days: POST archives the first one into history/ (#55). days: [ { date: "2026-09-29", models: { "claude-opus-5-5": { input: 1, output: 1, requests: 1 } } }, { date: "2026-09-30", models: { "claude-opus-5-5": { input: 1, output: 2, requests: 1 } } }, ], projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }], }; } function historyFixturePath(month) { return path.join(tmpDir, "claude-usage", "history", `${month}.json`); } function claudeUsageFixturePath() { return path.join(tmpDir, "claude-usage", "claude-usage.json"); } function writeJson(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(value)); } // One macrotask more, so a write deferred past res.end() is caught too. function settle() { return new Promise((resolve) => setTimeout(resolve, 20)); } let tmpDir; let server; let baseUrl; let bootWrites; // A transport failure (a connection cut before a 413 is read) comes back as // "transport-error", so it fails an assertion instead of passing for a code. async function send(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) { const headers = auth ? { Authorization: auth } : {}; if (body !== undefined) headers["Content-Type"] = "application/json"; try { const res = await fetch(`${baseUrl}${route}`, { method, headers, body }); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch { // Not JSON: the status alone is asserted. } return { status: res.status, body: json }; } catch { return { status: "transport-error", body: null }; } } // Created before the spies: nothing here is recorded. function writeFixtures() { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-")); const defenseurs = path.join(tmpDir, "defenseurs"); const reports = path.join(defenseurs, "reports"); const hosts = path.join(tmpDir, "hosts"); const report = (timestamp) => ({ agent: "defenseur-proj", timestamp, findings: [{ severity: "HIGH", category: "deps", title: "vulnerable dependency" }], }); writeJson(path.join(defenseurs, "status.json"), { schemaVersion: 1, timestamp: "2026-09-30T07:30:00.000Z", agents: [{ name: "defenseur-proj", status: "ok" }], }); writeJson(path.join(defenseurs, "findings-state.json"), { schemaVersion: 1, updatedAt: "2026-09-30T07:30:00.000Z", findings: { "proj:deps:1": { firstSeen: "2026-09-29T07:30:00.000Z" } }, }); writeJson(path.join(defenseurs, "agents-map.json"), { proj: "defenseur-proj" }); writeJson(path.join(reports, "defenseur-proj_2026-09-30T07-30-00-000Z.json"), report("2026-09-30T07:30:00.000Z")); writeJson(path.join(reports, "archive", "defenseur-proj_2026-09-29T07-30-00-000Z.json"), report("2026-09-29T07:30:00.000Z")); // A stored snapshot, so GET /hosts runs its real read path, not neverSeen. writeJson(path.join(hosts, "thinkpad.json"), { ...validSnapshot(), receivedAt: new Date().toISOString() }); // An orphan temp file older than an hour: the boot sweep must remove it, // which gives every test a write it can see (proof its spies are live). const orphan = path.join(hosts, ORPHAN); fs.writeFileSync(orphan, "{}"); const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(orphan, twoHoursAgo, twoHoursAgo); process.env.HEALTH_TOKEN = TOKEN; process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; process.env.REPORTS_DIR = reports; process.env.DEFENSEURS_STATUS_PATH = path.join(defenseurs, "status.json"); process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(defenseurs, "findings-state.json"); process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json"); process.env.HOSTS_DIR = hosts; process.env.HOSTS_ALLOWED_IDS = "thinkpad"; // The Claude usage snapshot (#48): its own directory, beside HOSTS_DIR and // not under it, so a write there is checked against its own rule. writeJson(claudeUsageFixturePath(), { ...validClaudeUsage(), receivedAt: new Date().toISOString() }); process.env.CLAUDE_USAGE_PATH = claudeUsageFixturePath(); // An archived month (#55), so GET /claude-usage/history runs its read path. writeJson(historyFixturePath("2026-09"), { schemaVersion: 1, month: "2026-09", days: [{ date: "2026-09-28", models: {} }], }); process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN; // Closed port: /health must never reach the real IdP. process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; } beforeEach(async () => { writeFixtures(); log = []; commandLog = []; installWriteSpies(); installCommandSpies(); purgeRuntimeModules(); const { handler, sweepStaleTempFiles } = require("../index.js"); sweepStaleTempFiles(); server = http.createServer(handler); await new Promise((resolve) => server.listen(0, resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; bootWrites = drain(); }); afterEach(async () => { vi.restoreAllMocks(); await new Promise((resolve) => server.close(() => resolve())); fs.rmSync(tmpDir, { recursive: true, force: true }); }); describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => { test("boot: module load and the temp-file sweep write only under HOSTS_DIR", () => { expect(bootWrites.map(({ fn }) => fn)).toContain("unlinkSync"); expect(fs.existsSync(path.join(process.env.HOSTS_DIR, ORPHAN))).toBe(false); expect(violations(bootWrites)).toEqual([]); expect(foreignCommands()).toEqual([]); }); // Each route with the content that proves its fixture was really read: a // missing mount answers 200 no_data, which must not pass for a clean read. const READ_ROUTES = [ ["/health", (body) => expect(body.cpu).toBeDefined()], ["/defenseurs", (body) => expect(body.agents).toHaveLength(1)], ["/defenseurs/state", (body) => expect(Object.keys(body.findings)).toHaveLength(1)], [ "/defenseurs/findings?project=proj", (body) => { expect(body.agent).toBe("defenseur-proj"); expect(body.findings).toHaveLength(1); }, ], ["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(1)], [ "/hosts", (body) => { const host = body.hosts.find(({ id }) => id === "thinkpad"); expect(host.neverSeen).toBeUndefined(); expect(host.receivedAt).toEqual(expect.any(String)); }, ], ["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)], ["/claude-usage/history", (body) => expect(body.days).toEqual([{ date: "2026-09-28", models: {} }])], ]; test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => { // This test's own spies are live: they saw the boot sweep. expect(bootWrites).not.toHaveLength(0); const { status, body } = await send(route); expect(status).toBe(200); expect(body.status).not.toBe("no_data"); expectContent(body); await settle(); expect(drain()).toEqual([]); expect(foreignCommands()).toEqual([]); }); test("POST /hosts/ writes, and only under HOSTS_DIR", async () => { const { status } = await send("/hosts/thinkpad", { method: "POST", auth: `Bearer ${INGEST_TOKEN}`, body: JSON.stringify(validSnapshot()), }); expect(status).toBe(204); await settle(); const writes = drain(); expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"])); expect(violations(writes)).toEqual([]); expect(foreignCommands()).toEqual([]); }); // The second write surface (#48). Its directory is the only one this case // allows: a write under HOSTS_DIR from this route would fail it too. test("POST /claude-usage writes, and only in the directory of CLAUDE_USAGE_PATH", async () => { const { status } = await send("/claude-usage", { method: "POST", auth: `Bearer ${CLAUDE_TOKEN}`, body: JSON.stringify(validClaudeUsage()), }); expect(status).toBe(204); await settle(); const writes = drain(); expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"])); expect(violations(writes, path.dirname(process.env.CLAUDE_USAGE_PATH))).toEqual([]); // The archive (#55) is part of this surface, and the case proves it ran: // a rename onto the month file of the closed day. expect(writes.some(({ fn, targets }) => fn === "renameSync" && targets.includes(historyFixturePath("2026-09")))).toBe(true); expect(foreignCommands()).toEqual([]); }); test("POST /claude-usage rejections and invalid input write nothing at all", async () => { expect(bootWrites).not.toHaveLength(0); const claude = `Bearer ${CLAUDE_TOKEN}`; const rejected = [ [401, { auth: null, body: "{}" }], [401, { auth: `Bearer ${INGEST_TOKEN}`, body: JSON.stringify(validClaudeUsage()) }], [400, { auth: claude, body: "{not json" }], [400, { auth: claude, body: JSON.stringify({ ...validClaudeUsage(), schemaVersion: 2 }) }], [413, { auth: claude, body: "x".repeat(66000) }], ]; for (const [expected, options] of rejected) { const { status } = await send("/claude-usage", { method: "POST", ...options }); expect([options.body.length, status]).toEqual([options.body.length, expected]); } // A corrupt stored snapshot: where a "repair it on read" write would go. real.writeFileSync(process.env.CLAUDE_USAGE_PATH, "{corrupt"); const { status, body } = await send("/claude-usage"); expect(status).toBe(200); expect(body).toEqual({ status: "no_data" }); // Same for a corrupt month of the archive, and for a refused range (#55). real.writeFileSync(historyFixturePath("2026-09"), "{corrupt"); const history = await send("/claude-usage/history?from=2026-09&to=2026-09"); expect([history.status, history.body]).toEqual([200, { from: "2026-09", to: "2026-09", days: [] }]); expect((await send("/claude-usage/history?from=2026-13")).status).toBe(400); await settle(); expect(drain()).toEqual([]); expect(foreignCommands()).toEqual([]); }); // The branches the nominal cases never take, and where the most plausible // future writes would land: quarantining a corrupt file, recreating a // missing one, logging a rejection or a refused payload to disk. test("rejections, invalid input, corrupt and missing files write nothing outside HOSTS_DIR", async () => { expect(bootWrites).not.toHaveLength(0); const ingest = `Bearer ${INGEST_TOKEN}`; const rejected = [ [401, "/defenseurs", { auth: null }], [401, "/defenseurs", { auth: "Bearer wrong" }], [401, "/hosts", { auth: ingest }], [404, "/nope", {}], [400, "/defenseurs/findings", {}], [400, "/defenseurs/findings?project=proj&severity=BOGUS", {}], [404, "/defenseurs/findings?project=unknown", {}], [400, "/reports/scans?date=../../etc", {}], [401, "/hosts/thinkpad", { method: "POST", auth: "Bearer wrong", body: "{}" }], [403, "/hosts/laptop", { method: "POST", auth: ingest, body: JSON.stringify(validSnapshot()) }], [400, "/hosts/thinkpad", { method: "POST", auth: ingest, body: "{not json" }], [400, "/hosts/thinkpad", { method: "POST", auth: ingest, body: JSON.stringify({ hostname: "x" }) }], [413, "/hosts/thinkpad", { method: "POST", auth: ingest, body: "x".repeat(5000) }], ]; for (const [expected, route, options] of rejected) { const { status } = await send(route, options); expect([options.method || "GET", route, status]).toEqual([options.method || "GET", route, expected]); } // A corrupt agents-map.json: where a quarantine rename would go. real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt"); expect((await send("/defenseurs/findings?project=proj")).status).toBe(500); // Missing Defenseurs files and a corrupt snapshot: where a "recreate it" // write would go. Each route must degrade, with the content that says so. // The reports directory is moved away rather than removed: one call. real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" })); real.rmSync(process.env.DEFENSEURS_STATUS_PATH); real.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH); real.renameSync(process.env.REPORTS_DIR, path.join(tmpDir, "reports-moved-away")); real.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt"); const degraded = [ ["/defenseurs", (body) => expect(body.status).toBe("no_data")], ["/defenseurs/state", (body) => expect(body.status).toBe("no_data")], ["/defenseurs/findings?project=proj", (body) => expect(body).toEqual({ findings: [], status: "no_data" })], ["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(0)], ["/hosts", (body) => expect(body.hosts.find(({ id }) => id === "thinkpad").neverSeen).toBe(true)], ]; for (const [route, expectContent] of degraded) { const { status, body } = await send(route); expect([route, status]).toEqual([route, 200]); expectContent(body); } await settle(); expect(violations(drain())).toEqual([]); expect(foreignCommands()).toEqual([]); }); }); describe("shell commands — only the two read-only metrics ones (#36)", () => { // Positive control for the child_process spies: /health must show exactly // the two commands. The other cases only check that nothing else ran. test("GET /health runs exactly `head -1 /proc/stat` and `df -k /`", async () => { const { status } = await send("/health"); expect(status).toBe(200); expect([...new Set(commandLog)].sort()).toEqual([...ALLOWED_COMMANDS].sort()); }); }); describe("fs writes — the entrypoint adds no write of its own", () => { // The block below never runs in-process: the tests above stand in for it by // calling the exported sweep. This pin keeps that stand-in honest. A new call // in the block (a PID file, a marker) must be looked at, then allowed here. // The comparison is an exact set, so a block that cannot be found or comes // back empty fails instead of passing vacuously. test("the main block only sweeps, creates the server and listens", () => { const source = fs.readFileSync(path.join(REPO_ROOT, "index.js"), "utf-8"); const start = source.indexOf("if (require.main === module) {"); expect(start).toBeGreaterThan(-1); const block = source.slice(start, source.indexOf("\n}\n", start) + 2); const calls = [...block.matchAll(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)] .map((match) => match[1]) .filter((name) => name !== "if"); expect([...new Set(calls)].sort()).toEqual( ["console.log", "http.createServer", "server.listen", "sweepStaleTempFiles"].sort(), ); }); });