const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); // PostgreSQL backups (issue #58): GET /backups read by the admin dashboard and // /warmup, POST /backups/pull sent by the workstation pull agent. // // Four things are pinned here. // * The two validators, as pure functions (backups.js): status.json goes // through a whitelist rebuild where an invalid value is dropped, never // coerced; the pull body is validated strictly, every bad field a 400. // * The gate: a fourth token kind, `backups-ingest`, isolated both ways from // the three others, failing closed to a 503 that names its variable. // * The read: no_data only when both files are absent, a broken file giving // a null block plus its error code (never a 500), and the freshness of // each block decided by the server (VPS on generatedAt, workstation on the // server-stamped receivedAt). // * The write: ceiling, atomic write in mode 600, server receivedAt. const TOKEN = "test-token"; const INGEST_TOKEN = "test-ingest-token"; const CLAUDE_TOKEN = "test-claude-token"; const BACKUPS_TOKEN = "test-backups-token"; // Restated rather than imported, so a silent change to a constant turns the // matrix red instead of quietly re-drawing it. const BACKUPS_MAX_BODY_BYTES = 1024; const DEFAULT_STALE_SECONDS = 129600; const FROZEN_NOW = Date.UTC(2026, 9, 11, 12, 0, 0); let tmpDir; let statusPath; let pullPath; let server; let baseUrl; // What backup-postgres.sh writes, rotation block included (issue comment of // 2026-10-10), one failed database among them, and the files block of #61 in // the script's real shapes: a failed encryption keeps the archive's time and // size, a missing source has neither. function validStatus() { return { schemaVersion: 1, generatedAt: "2026-10-11T03:04:12Z", ok: false, expectedCount: 2, databases: [ { name: "maximus_api", sizeBytes: 812345, dumpedAt: "2026-10-11T03:00:41Z", ok: true }, { name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false, error: "dump_failed" }, ], globals: { sizeBytes: 4096, ok: true }, files: [ { name: "claude-usage", sizeBytes: 2466, archivedAt: "2026-10-11T03:00:43Z", ok: true }, { name: "roadtrip-uploads", sizeBytes: 0, archivedAt: null, ok: false, error: "source_missing" }, { name: "hosts", sizeBytes: 512, archivedAt: "2026-10-11T03:00:44Z", ok: false, error: "encrypt_failed" }, ], mirror: { ok: true, uploadedAt: "2026-10-11T03:04:10Z" }, rotation: { ok: true }, retentionDays: { local: 7, offsite: 30 }, }; } // What run-pg-backup-pull.sh sends. function validPull() { return { pulledAt: "2026-10-11T04:10:00Z", files: 9, ok: true }; } function freshModule() { delete require.cache[require.resolve("../backups.js")]; return require("../backups.js"); } function startServer() { for (const mod of ["../index.js", "../backups.js", "../claude-usage.js"]) { delete require.cache[require.resolve(mod)]; } const { handler } = require("../index.js"); server = http.createServer(handler); return new Promise((resolve) => { server.listen(0, () => { baseUrl = `http://127.0.0.1:${server.address().port}`; resolve(); }); }); } function stopServer() { return new Promise((resolve) => { if (!server) return resolve(); server.close(() => resolve()); }); } async function restartServer() { await stopServer(); await startServer(); } 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 push({ auth = `Bearer ${BACKUPS_TOKEN}`, body = JSON.stringify(validPull()) } = {}) { return request("/backups/pull", { method: "POST", auth, body }); } function read(auth = `Bearer ${TOKEN}`) { return request("/backups", { auth }); } async function frozenRead() { const realNow = Date.now; Date.now = () => FROZEN_NOW; try { return await read(); } finally { Date.now = realNow; } } // The 413 must come back as a status; a dead socket is reported as such. async function pushOversized(body) { try { return (await push({ body })).status; } catch (err) { return `transport error instead of a status: ${err.cause?.code || err.code || err.message}`; } } // A JSON body of exactly `bytes` bytes around a payload that is otherwise valid. function bodyOfExactly(bytes) { const padded = { ...validPull(), padding: "" }; const overhead = JSON.stringify(padded).length; return JSON.stringify({ ...padded, padding: "x".repeat(bytes - overhead) }); } function writeFile(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value)); } function iso(ms) { return new Date(ms).toISOString(); } // Silences the expected [backups] log lines and hands them back. async function withQuietErrors(fn) { const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { const result = await fn(); return { result, lines: error.mock.calls.map((args) => args.join(" ")) }; } finally { error.mockRestore(); } } beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-backups-test-")); statusPath = path.join(tmpDir, "backups-status", "status.json"); pullPath = path.join(tmpDir, "backups-pull", "pull.json"); process.env.HEALTH_TOKEN = TOKEN; process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN; process.env.BACKUPS_INGEST_TOKEN = BACKUPS_TOKEN; process.env.BACKUPS_STATUS_PATH = statusPath; process.env.BACKUPS_PULL_PATH = pullPath; delete process.env.BACKUPS_STALE_SECONDS; process.env.CLAUDE_USAGE_PATH = path.join(tmpDir, "claude-usage", "claude-usage.json"); process.env.HOSTS_DIR = path.join(tmpDir, "hosts"); process.env.HOSTS_ALLOWED_IDS = "thinkpad"; 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"); process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json"); // Closed port: no request may reach the real IdP. process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; await startServer(); }); afterEach(async () => { await stopServer(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); afterAll(() => { // Other files reuse the worker: leave no backups config behind. for (const name of [ "BACKUPS_INGEST_TOKEN", "BACKUPS_STATUS_PATH", "BACKUPS_PULL_PATH", "BACKUPS_STALE_SECONDS", "CLAUDE_USAGE_INGEST_TOKEN", "CLAUDE_USAGE_PATH", ]) { delete process.env[name]; } }); // --- status.json, as a pure function ---------------------------------------- describe("sanitizeBackupsStatus — the whitelist", () => { test("the status the VPS script writes comes back identical, rotation and retentionDays included", () => { const { sanitizeBackupsStatus } = freshModule(); expect(sanitizeBackupsStatus(validStatus())).toEqual({ status: validStatus() }); }); test("it is idempotent, so the read path can run it on any stored file", () => { const { sanitizeBackupsStatus } = freshModule(); const noisy = { ...validStatus(), extra: 1, expectedCount: -1 }; const once = sanitizeBackupsStatus(noisy).status; expect(sanitizeBackupsStatus(once).status).toEqual(once); }); test("the nullable fields keep an explicit null", () => { const { sanitizeBackupsStatus } = freshModule(); const payload = validStatus(); payload.mirror = { ok: false, uploadedAt: null, error: "mirror_upload_failed" }; const { status } = sanitizeBackupsStatus(payload); expect(status.databases[1].dumpedAt).toBeNull(); expect(status.mirror).toEqual({ ok: false, uploadedAt: null, error: "mirror_upload_failed" }); }); test("an unknown field is dropped at every level, __proto__ included", () => { const { sanitizeBackupsStatus } = freshModule(); const payload = JSON.parse(JSON.stringify(validStatus())); payload.stderr = "pg_dump: connection to host 10.0.0.5 failed"; payload.databases[0].host = "10.0.0.5"; payload.files[0].path = "/data/claude-usage"; payload.files[1] = JSON.parse(`{"__proto__": {"polluted": true}, ${JSON.stringify(payload.files[1]).slice(1)}`); payload.globals.path = "/var/backups/postgres/globals.sql"; payload.mirror.endpoint = "http://minio:9000"; payload.rotation.removed = ["2026-10-01"]; payload.retentionDays.mirror = 7; const hostile = JSON.parse(`{"__proto__": {"polluted": true}, ${JSON.stringify(payload).slice(1)}`); const { status } = sanitizeBackupsStatus(hostile); expect(status).toEqual(validStatus()); expect(Object.getPrototypeOf(status)).toBe(Object.prototype); expect({}.polluted).toBeUndefined(); }); test("an error code outside the closed list is dropped, ok:false is kept", () => { const { sanitizeBackupsStatus } = freshModule(); const payload = validStatus(); payload.databases[1].error = "pg_dump: FATAL: password authentication failed"; payload.globals = { sizeBytes: 0, ok: false, error: "Dump_Failed" }; payload.mirror = { ok: false, uploadedAt: null, error: "rclone: 403 Forbidden" }; payload.rotation = { ok: false, error: "rm: cannot remove" }; const { status } = sanitizeBackupsStatus(payload); expect(status.databases[1]).toEqual({ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false }); expect(status.globals).toEqual({ sizeBytes: 0, ok: false }); expect(status.mirror).toEqual({ ok: false, uploadedAt: null }); expect(status.rotation).toEqual({ ok: false }); }); test.each([ "dump_failed", "dump_empty", "restore_list_failed", "invalid_name", "encrypt_failed", "mirror_upload_failed", "mirror_purge_failed", "rotation_failed", ])("the code %s is kept", (code) => { const { sanitizeBackupsStatus } = freshModule(); const payload = validStatus(); payload.databases[1].error = code; payload.rotation = { ok: false, error: code }; const { status } = sanitizeBackupsStatus(payload); expect(status.databases[1].error).toBe(code); expect(status.rotation.error).toBe(code); }); test("an invalid value is dropped, never coerced", () => { const { sanitizeBackupsStatus } = freshModule(); const payload = validStatus(); payload.ok = "true"; payload.expectedCount = 2.5; payload.databases[0] = { name: "maximus_api", sizeBytes: -1, dumpedAt: "2026-10-11", ok: 1 }; payload.globals.sizeBytes = 2 ** 53; payload.mirror.uploadedAt = "2026-10-11T03:04:10"; payload.retentionDays = { local: "7", offsite: 30 }; const { status } = sanitizeBackupsStatus(payload); expect(status).not.toHaveProperty("ok"); expect(status).not.toHaveProperty("expectedCount"); expect(status.databases[0]).toEqual({ name: "maximus_api" }); expect(status.globals).toEqual({ ok: true }); expect(status.mirror).toEqual({ ok: true }); expect(status.retentionDays).toEqual({ offsite: 30 }); }); test("an invalid offsite retention is dropped, local kept", () => { const { sanitizeBackupsStatus } = freshModule(); const payload = validStatus(); payload.retentionDays = { local: 7, offsite: "30" }; expect(sanitizeBackupsStatus(payload).status.retentionDays).toEqual({ local: 7 }); payload.retentionDays = { local: 7, offsite: -1 }; expect(sanitizeBackupsStatus(payload).status.retentionDays).toEqual({ local: 7 }); }); test("a database name the VPS script refused is dropped, the entry and its code kept", () => { const { sanitizeBackupsStatus } = freshModule(); const payload = validStatus(); payload.databases[1] = { name: "Bad Name