vps-health-api/__tests__/writes.test.js
le king fu 5912f08e41
All checks were successful
CI / verification (pull_request) Successful in 23s
CI / verification (push) Successful in 20s
test(writes): count every write, drop the stack attribution (#40)
A node:fs function handed as-is to an asynchronous mechanism
(process.nextTick(writeFileSync, ...), a bound listener, a .then) runs with
no runtime frame on its stack, so the stack filter let the write through on
a path the suite does exercise.

The spies now record every call. The test's own fixture edits go through
the real functions captured before any spy exists. The recursive rmSync of
the reports directory becomes a rename: rmSync recurses through the public
fs.rmdirSync, which is the spy by then.

Fixes #40

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:01:09 -04:00

445 lines
19 KiB
JavaScript

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 only write surface is HOSTS_DIR (issue #36).
//
// 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 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: `<dir>/x` creates `<dir>/xXXXXXX` below
// <dir>, but `<dir>` alone creates the sibling `<dir>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 HOSTS_DIR or below it. path.relative rather than startsWith, so a
// sibling such as `<tmp>/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) {
const hostsDir = path.resolve(process.env.HOSTS_DIR);
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 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";
// 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));
},
],
];
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/<id> 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 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(),
);
});
});