vps-health-api/__tests__/writes.test.js
le king fu 1b619249e2
All checks were successful
CI / verification (pull_request) Successful in 36s
feat(backups): GET /backups and POST /backups/pull
Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.

- GET /backups (read token) serves { vps, lenovo }: the status.json the
  VPS script writes, rebuilt through a whitelist (closed error codes,
  rotation block included), and the last workstation pull report. Each
  block gets ageSeconds, stale and staleAfterSeconds from the server
  (BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
  server-stamped receivedAt. A broken file gives a null block plus
  vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
  BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
  { pulledAt, files, ok, error? }, server receivedAt, atomic write in
  mode 600 through writeJsonAtomic(), now shared with /claude-usage.
  Answers 204 and 413 like the two other ingest routes (the agent only
  checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
  (101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.

Refs #58

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:43:07 -04:00

618 lines
27 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 write surfaces are HOSTS_DIR (issue #36), since #48 the
// directory of CLAUDE_USAGE_PATH, written by POST /claude-usage only, and since
// #58 the directory of BACKUPS_PULL_PATH, written by POST /backups/pull only.
// Every case below allows HOSTS_DIR alone, except the two POST cases of those
// routes, each of which allows its own directory alone. The directory of
// BACKUPS_STATUS_PATH is a read-only mount in production, like the Defenseurs
// ones: nothing may ever write there.
//
// 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 BACKUPS_TOKEN = "test-backups-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 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 `<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, 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 validBackupsStatus() {
return {
schemaVersion: 1,
generatedAt: "2026-10-11T03:04:12Z",
ok: true,
expectedCount: 1,
databases: [{ name: "maximus_api", sizeBytes: 812345, dumpedAt: "2026-10-11T03:00:41Z", ok: true }],
globals: { sizeBytes: 4096, ok: true },
mirror: { ok: true, uploadedAt: "2026-10-11T03:04:10Z" },
rotation: { ok: true },
retentionDays: { local: 7, offsite: 30 },
};
}
function validBackupsPull() {
return { pulledAt: "2026-10-11T04:10:00Z", files: 9, ok: true };
}
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;
// The backups (#58): status.json on what is a read-only mount in production,
// and the pull report in its own writable directory.
writeJson(path.join(tmpDir, "backups-status", "status.json"), validBackupsStatus());
writeJson(path.join(tmpDir, "backups-pull", "pull.json"), { ...validBackupsPull(), receivedAt: new Date().toISOString() });
process.env.BACKUPS_STATUS_PATH = path.join(tmpDir, "backups-status", "status.json");
process.env.BACKUPS_PULL_PATH = path.join(tmpDir, "backups-pull", "pull.json");
process.env.BACKUPS_INGEST_TOKEN = BACKUPS_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: {} }])],
[
"/backups",
(body) => {
expect(body.vps.databases).toHaveLength(1);
expect(body.lenovo.files).toBe(9);
},
],
];
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 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([]);
});
// The third write surface (#58), same rule: its own directory alone.
test("POST /backups/pull writes, and only in the directory of BACKUPS_PULL_PATH", async () => {
const { status } = await send("/backups/pull", {
method: "POST",
auth: `Bearer ${BACKUPS_TOKEN}`,
body: JSON.stringify(validBackupsPull()),
});
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.BACKUPS_PULL_PATH))).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
test("POST /backups/pull rejections, and GET /backups on corrupt files, write nothing at all", async () => {
expect(bootWrites).not.toHaveLength(0);
const backups = `Bearer ${BACKUPS_TOKEN}`;
const rejected = [
[401, { auth: null, body: "{}" }],
[401, { auth: `Bearer ${CLAUDE_TOKEN}`, body: JSON.stringify(validBackupsPull()) }],
[400, { auth: backups, body: "{not json" }],
[400, { auth: backups, body: JSON.stringify({ ...validBackupsPull(), ok: false, error: "dump_failed" }) }],
[413, { auth: backups, body: "x".repeat(2000) }],
];
for (const [expected, options] of rejected) {
const { status } = await send("/backups/pull", { method: "POST", ...options });
expect([options.body.length, status]).toEqual([options.body.length, expected]);
}
// Corrupt files on both sides: where a quarantine or a repair would go.
real.writeFileSync(process.env.BACKUPS_STATUS_PATH, "{corrupt");
real.writeFileSync(process.env.BACKUPS_PULL_PATH, "{corrupt");
const { status, body } = await send("/backups");
expect(status).toBe(200);
expect(body).toEqual({ vps: null, lenovo: null, vpsError: "invalid_json", lenovoError: "invalid_json" });
// Both absent: where a "recreate it" write would go.
real.rmSync(process.env.BACKUPS_STATUS_PATH);
real.rmSync(process.env.BACKUPS_PULL_PATH);
expect((await send("/backups")).body).toEqual({ status: "no_data" });
await settle();
expect(drain()).toEqual([]);
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(),
);
});
});