vps-health-api/__tests__/backups.test.js
le king fu 3f0abc1871
All checks were successful
CI / verification (pull_request) Successful in 34s
CI / verification (push) Successful in 36s
test(backups): an invalid offsite retention is dropped
Review follow-up on #60: only `local` was checked with an invalid value,
so copying `retentionDays.offsite` unvalidated left the suite green.
Also name all four tokens in the Coolify runtime-only rule of CLAUDE.md.

Refs #58

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

766 lines
32 KiB
JavaScript

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.
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 },
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.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 <script>", sizeBytes: 0, dumpedAt: null, ok: false, error: "invalid_name" };
payload.databases.push({ name: "x".repeat(64), sizeBytes: 1, dumpedAt: null, ok: true });
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases[1]).toEqual({ sizeBytes: 0, dumpedAt: null, ok: false, error: "invalid_name" });
expect(status.databases[2]).not.toHaveProperty("name");
expect(status.databases).toHaveLength(3);
});
test("databases past 64 entries keep the first 64, non-objects are skipped", () => {
const { sanitizeBackupsStatus, MAX_DATABASES } = freshModule();
expect(MAX_DATABASES).toBe(64);
const payload = validStatus();
payload.databases = [
"not an object",
null,
...Array.from({ length: 70 }, (_, i) => ({ name: `db_${i}`, sizeBytes: i, dumpedAt: null, ok: true })),
];
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases).toHaveLength(64);
expect(status.databases[0].name).toBe("db_0");
expect(status.databases[63].name).toBe("db_63");
});
test("a component that is not an object is dropped", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = { ...validStatus(), globals: [], mirror: "ok", rotation: null, retentionDays: 7, databases: {} };
const { status } = sanitizeBackupsStatus(payload);
for (const key of ["globals", "mirror", "rotation", "retentionDays", "databases"]) {
expect(status).not.toHaveProperty(key);
}
});
test.each([
["an array", [], "invalid_contract"],
["null", null, "invalid_contract"],
["a string", "status", "invalid_contract"],
["schemaVersion 2", { ...validStatus(), schemaVersion: 2 }, "unsupported_schema"],
["schemaVersion \"1\"", { ...validStatus(), schemaVersion: "1" }, "unsupported_schema"],
["no schemaVersion", { ...validStatus(), schemaVersion: undefined }, "unsupported_schema"],
["no generatedAt", { ...validStatus(), generatedAt: undefined }, "invalid_contract"],
["a bare-date generatedAt", { ...validStatus(), generatedAt: "2026-10-11" }, "invalid_contract"],
["a generatedAt without offset", { ...validStatus(), generatedAt: "2026-10-11T03:04:12" }, "invalid_contract"],
])("refused as a whole: %s -> %s", (_label, payload, code) => {
const { sanitizeBackupsStatus } = freshModule();
expect(sanitizeBackupsStatus(payload)).toEqual({ error: code });
});
});
// --- The pull body, as a pure function --------------------------------------
describe("validateBackupsPull — strict", () => {
test("the bodies the agent sends pass, and only the four fields are kept", () => {
const { validateBackupsPull } = freshModule();
expect(validateBackupsPull(validPull())).toEqual({ pull: validPull() });
for (const error of ["pull_failed", "rotation_failed"]) {
const body = { pulledAt: "2026-10-11T04:10:00Z", files: 0, ok: false, error };
expect(validateBackupsPull(body)).toEqual({ pull: body });
}
const noisy = { ...validPull(), receivedAt: "2000-01-01T00:00:00Z", host: "max-ThinkPad-T490" };
expect(validateBackupsPull(noisy)).toEqual({ pull: validPull() });
});
test.each([
["an array", []],
["null", null],
["no pulledAt", { files: 9, ok: true }],
["a bare-date pulledAt", { ...validPull(), pulledAt: "2026-10-11" }],
["a pulledAt that is not a date", { ...validPull(), pulledAt: "2026-02-31T00:00:00Z!" }],
["no files", { pulledAt: "2026-10-11T04:10:00Z", ok: true }],
["negative files", { ...validPull(), files: -1 }],
["fractional files", { ...validPull(), files: 1.5 }],
["files as a string", { ...validPull(), files: "9" }],
["files past 2^53", { ...validPull(), files: 2 ** 53 }],
["no ok", { pulledAt: "2026-10-11T04:10:00Z", files: 9 }],
["ok as a string", { ...validPull(), ok: "true" }],
["ok:false without an error", { ...validPull(), ok: false }],
["ok:false with a code outside the list", { ...validPull(), ok: false, error: "dump_failed" }],
["ok:false with free text", { ...validPull(), ok: false, error: "rsync: connection unexpectedly closed" }],
["ok:true with an error", { ...validPull(), error: "pull_failed" }],
])("refused: %s", (_label, payload) => {
const { validateBackupsPull } = freshModule();
expect(validateBackupsPull(payload).error).toEqual(expect.any(String));
});
});
// --- The gate ---------------------------------------------------------------
describe("POST /backups/pull — authentication", () => {
test("503 naming its own variable when BACKUPS_INGEST_TOKEN is not set", async () => {
delete process.env.BACKUPS_INGEST_TOKEN;
await restartServer();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = await push();
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "BACKUPS_INGEST_TOKEN not configured" });
} finally {
warn.mockRestore();
}
expect(fs.existsSync(pullPath)).toBe(false);
});
test("the other routes still answer when BACKUPS_INGEST_TOKEN is not set", async () => {
delete process.env.BACKUPS_INGEST_TOKEN;
await restartServer();
expect((await read()).status).toBe(200);
});
test.each([
["no Authorization header", null],
["a wrong token", "Bearer wrong-token"],
["the read token", `Bearer ${TOKEN}`],
["the hosts ingest token", `Bearer ${INGEST_TOKEN}`],
["the Claude usage ingest token", `Bearer ${CLAUDE_TOKEN}`],
["the backups token without the Bearer scheme", BACKUPS_TOKEN],
])("401 with %s, nothing written", async (_label, auth) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = await push({ auth });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Unauthorized" });
} finally {
warn.mockRestore();
}
expect(fs.existsSync(pullPath)).toBe(false);
});
test("each rejection is logged without the token", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await push({ auth: `Bearer ${CLAUDE_TOKEN}` });
const lines = warn.mock.calls.map((args) => args.join(" "));
expect(lines).toEqual([expect.stringMatching(/^\[auth\] 401 POST \/backups\/pull /)]);
expect(lines.join("\n")).not.toContain(CLAUDE_TOKEN);
} finally {
warn.mockRestore();
}
});
test.each([
["GET /backups", "/backups", "GET"],
["GET /hosts", "/hosts", "GET"],
["GET /claude-usage", "/claude-usage", "GET"],
["POST /hosts/thinkpad", "/hosts/thinkpad", "POST"],
["POST /claude-usage", "/claude-usage", "POST"],
])("the backups token opens nothing else: 401 on %s", async (_label, route, method) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const body = method === "POST" ? "{}" : undefined;
const res = await request(route, { method, auth: `Bearer ${BACKUPS_TOKEN}`, body });
expect(res.status).toBe(401);
} finally {
warn.mockRestore();
}
});
test.each([
["no token", null],
["the backups ingest token", `Bearer ${BACKUPS_TOKEN}`],
])("401 on GET /backups with %s", async (_label, auth) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
expect((await read(auth)).status).toBe(401);
} finally {
warn.mockRestore();
}
});
// Routing comes first: an unauthenticated caller on a route that does not
// exist sees 404, never 401.
test.each([
["GET", "/backups/pull"],
["POST", "/backups"],
["PUT", "/backups/pull"],
["GET", "/backups/"],
])("404 (not 401) on %s %s without a token", async (method, route) => {
const res = await request(route, { method });
expect(res.status).toBe(404);
});
test("the comparison goes through timingSafeEqual on two digests", async () => {
const crypto = require("node:crypto");
const spy = vi.spyOn(crypto, "timingSafeEqual");
try {
await restartServer();
expect((await push({ auth: "Bearer x" })).status).toBe(401);
expect(spy).toHaveBeenCalled();
for (const [a, b] of spy.mock.calls) {
expect([a.length, b.length]).toEqual([32, 32]);
}
} finally {
spy.mockRestore();
}
});
});
// --- Writing ----------------------------------------------------------------
describe("POST /backups/pull — validation and write", () => {
test("204, the file holds the four fields plus a server receivedAt, mode 600", async () => {
const before = Date.now();
const sent = { ...validPull(), extra: "dropped", receivedAt: "2000-01-01T00:00:00.000Z" };
const res = await push({ body: JSON.stringify(sent) });
expect(res.status).toBe(204);
const { receivedAt, ...stored } = JSON.parse(fs.readFileSync(pullPath, "utf-8"));
expect(stored).toEqual(validPull());
// The client's receivedAt is ignored: the server stamps its own.
expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000);
expect(new Date(receivedAt).getTime()).toBeLessThanOrEqual(Date.now());
expect(fs.statSync(pullPath).mode & 0o777).toBe(0o600);
// Atomic write: no temp file left beside the report.
expect(fs.readdirSync(path.dirname(pullPath))).toEqual(["pull.json"]);
});
test("204 on a failed pull report, its code stored", async () => {
const body = { pulledAt: "2026-10-11T04:10:00Z", files: 0, ok: false, error: "pull_failed" };
expect((await push({ body: JSON.stringify(body) })).status).toBe(204);
const { receivedAt, ...stored } = JSON.parse(fs.readFileSync(pullPath, "utf-8"));
expect(stored).toEqual(body);
expect(receivedAt).toEqual(expect.any(String));
});
test("a second report replaces the first", async () => {
await push();
expect((await push({ body: JSON.stringify({ ...validPull(), files: 10 }) })).status).toBe(204);
expect(JSON.parse(fs.readFileSync(pullPath, "utf-8")).files).toBe(10);
});
test.each([
["not JSON", "{not json", "Bad request: body must be valid JSON"],
["a JSON array", "[]", "Bad request: body must be a JSON object"],
["a missing field", JSON.stringify({ pulledAt: "2026-10-11T04:10:00Z", ok: true }), "Bad request: files must be a non-negative integer"],
[
"an error code outside the list",
JSON.stringify({ ...validPull(), ok: false, error: "dump_failed" }),
"Bad request: error must be one of pull_failed,rotation_failed when ok is false",
],
])("400 on %s, nothing written", async (_label, body, message) => {
const res = await push({ body });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: message });
expect(fs.existsSync(pullPath)).toBe(false);
});
test("400 leaves the previous report in place", async () => {
await push();
const before = fs.readFileSync(pullPath, "utf-8");
expect((await push({ body: JSON.stringify({ ...validPull(), files: -1 }) })).status).toBe(400);
expect(fs.readFileSync(pullPath, "utf-8")).toBe(before);
});
// 413, not the 400 the issue named: same answer as the two other ingest
// routes, from the same readBody(). The agent only checks for a 2xx.
test("204 at exactly 1024 bytes, 413 at 1025, nothing written", async () => {
expect(Buffer.byteLength(bodyOfExactly(BACKUPS_MAX_BODY_BYTES))).toBe(1024);
expect(await pushOversized(bodyOfExactly(BACKUPS_MAX_BODY_BYTES))).toBe(204);
fs.rmSync(pullPath);
expect(await pushOversized(bodyOfExactly(BACKUPS_MAX_BODY_BYTES + 1))).toBe(413);
expect(fs.existsSync(pullPath)).toBe(false);
});
test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => {
// The target is a non-empty directory: mkdir and the temp write succeed,
// renameSync fails, and only writeJsonAtomic() can remove the temp file.
fs.mkdirSync(path.join(pullPath, "occupied"), { recursive: true });
const { result, lines } = await withQuietErrors(() => push());
expect(result.status).toBe(500);
expect(result.body).toEqual({ error: "Internal error" });
expect(fs.readdirSync(path.dirname(pullPath))).toEqual(["pull.json"]);
// The log names the error code, never the path.
expect(lines).toEqual([expect.stringMatching(/^\[backups\] failed to persist pull report \(E[A-Z]+\)$/)]);
expect(lines.join("\n")).not.toContain(tmpDir);
});
});
// --- Reading ----------------------------------------------------------------
describe("GET /backups", () => {
test("no file at all -> { status: \"no_data\" }", async () => {
const res = await read();
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "no_data" });
});
test("both blocks, each with the server's freshness, under a frozen clock", async () => {
writeFile(statusPath, validStatus());
writeFile(pullPath, { ...validPull(), receivedAt: "2026-10-11T04:10:05.000Z" });
const res = await frozenRead();
expect(res.status).toBe(200);
expect(res.body).toEqual({
vps: {
...validStatus(),
// 2026-10-11T03:04:12Z -> 12:00:00Z
ageSeconds: 8 * 3600 + 55 * 60 + 48,
stale: false,
staleAfterSeconds: DEFAULT_STALE_SECONDS,
},
lenovo: {
...validPull(),
receivedAt: "2026-10-11T04:10:05.000Z",
ageSeconds: 7 * 3600 + 49 * 60 + 55,
stale: false,
staleAfterSeconds: DEFAULT_STALE_SECONDS,
},
});
});
test("the workstation is aged on the server's receivedAt, never on its pulledAt", async () => {
// pulledAt from a clock three days behind; received an hour ago.
writeFile(pullPath, { ...validPull(), pulledAt: iso(FROZEN_NOW - 3 * 86400 * 1000), receivedAt: iso(FROZEN_NOW - 3600 * 1000) });
const { body } = await frozenRead();
expect(body.lenovo.ageSeconds).toBe(3600);
expect(body.lenovo.stale).toBe(false);
});
test("POST then GET: what the agent sent, with the receivedAt the server stamped", async () => {
expect((await push()).status).toBe(204);
const { receivedAt } = JSON.parse(fs.readFileSync(pullPath, "utf-8"));
const { status, body } = await read();
expect(status).toBe(200);
expect(body.vps).toBeNull();
expect(body).not.toHaveProperty("vpsError");
expect(body.lenovo).toEqual({
...validPull(),
receivedAt,
ageSeconds: expect.any(Number),
stale: false,
staleAfterSeconds: DEFAULT_STALE_SECONDS,
});
expect(body.lenovo.ageSeconds).toBeLessThanOrEqual(1);
});
test("VPS only: lenovo is null, with no lenovoError", async () => {
writeFile(statusPath, validStatus());
const { body } = await read();
expect(body.lenovo).toBeNull();
expect(body).not.toHaveProperty("lenovoError");
expect(body.vps.generatedAt).toBe("2026-10-11T03:04:12Z");
});
test.each([
["the VPS block", () => writeFile(statusPath, { ...validStatus(), generatedAt: iso(FROZEN_NOW - DEFAULT_STALE_SECONDS * 1000) }), "vps"],
["the workstation block", () => writeFile(pullPath, { ...validPull(), receivedAt: iso(FROZEN_NOW - DEFAULT_STALE_SECONDS * 1000) }), "lenovo"],
])("%s: fresh at exactly 36 h, stale one second later", async (_label, write, key) => {
write();
const fresh = (await frozenRead()).body[key];
expect([fresh.ageSeconds, fresh.stale]).toEqual([DEFAULT_STALE_SECONDS, false]);
const realNow = Date.now;
Date.now = () => FROZEN_NOW + 1000;
try {
const stale = (await read()).body[key];
expect([stale.ageSeconds, stale.stale]).toEqual([DEFAULT_STALE_SECONDS + 1, true]);
} finally {
Date.now = realNow;
}
});
test("a generatedAt in the future gives an age of 0, not a negative one", async () => {
writeFile(statusPath, { ...validStatus(), generatedAt: iso(FROZEN_NOW + 3600 * 1000) });
const { body } = await frozenRead();
expect([body.vps.ageSeconds, body.vps.stale]).toEqual([0, false]);
});
test("BACKUPS_STALE_SECONDS moves the threshold", async () => {
process.env.BACKUPS_STALE_SECONDS = "60";
await restartServer();
writeFile(statusPath, { ...validStatus(), generatedAt: iso(FROZEN_NOW - 61 * 1000) });
const { body } = await frozenRead();
expect([body.vps.staleAfterSeconds, body.vps.stale]).toEqual([60, true]);
});
test.each(["abc", "0", "-5", ""])("BACKUPS_STALE_SECONDS=%j falls back to the default", async (value) => {
process.env.BACKUPS_STALE_SECONDS = value;
await restartServer();
writeFile(statusPath, validStatus());
const { body } = await frozenRead();
expect(body.vps.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS);
});
test("an unknown field in status.json is not returned", async () => {
const hostile = validStatus();
hostile.stderr = "pg_dump: password authentication failed for user postgres";
hostile.databases[0].path = "/var/backups/postgres/2026-10-11/maximus_api.dump";
hostile.mirror.error = "rclone: AccessDenied for key AKIA";
writeFile(statusPath, hostile);
const { body } = await read();
const text = JSON.stringify(body);
for (const leak of ["stderr", "password", "/var/backups", "AKIA"]) {
expect(text).not.toContain(leak);
}
expect(body.vps.databases[0]).toEqual(validStatus().databases[0]);
});
test.each([
["not JSON", "{corrupt", "invalid_json"],
["schemaVersion 2", JSON.stringify({ ...validStatus(), schemaVersion: 2 }), "unsupported_schema"],
["no generatedAt", JSON.stringify({ ...validStatus(), generatedAt: undefined }), "invalid_contract"],
["a JSON array", "[]", "invalid_contract"],
])("status.json %s: vps null + vpsError %s, the workstation still served, never a 500", async (_label, content, code) => {
writeFile(statusPath, content);
writeFile(pullPath, { ...validPull(), receivedAt: new Date().toISOString() });
const { result, lines } = await withQuietErrors(() => read());
expect(result.status).toBe(200);
expect(result.body.vps).toBeNull();
expect(result.body.vpsError).toBe(code);
expect(result.body.lenovo.files).toBe(9);
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[backups\] status\.json /);
});
test("status.json broken and no pull file: the error is reported, not hidden behind no_data", async () => {
writeFile(statusPath, "{corrupt");
const { result } = await withQuietErrors(() => read());
expect(result.body).toEqual({ vps: null, lenovo: null, vpsError: "invalid_json" });
});
test("status.json that cannot be read (a directory): vpsError unreadable, the code logged, not the path", async () => {
fs.mkdirSync(statusPath, { recursive: true });
const { result, lines } = await withQuietErrors(() => read());
expect(result.status).toBe(200);
expect(result.body).toEqual({ vps: null, lenovo: null, vpsError: "unreadable" });
expect(lines).toEqual(["[backups] status.json unreadable (EISDIR)"]);
});
test.each([
["not JSON", "{corrupt", "invalid_json"],
["no receivedAt", JSON.stringify(validPull()), "invalid_contract"],
["a receivedAt that is not a full ISO date", JSON.stringify({ ...validPull(), receivedAt: "1" }), "invalid_contract"],
["an invalid field", JSON.stringify({ ...validPull(), files: -1, receivedAt: new Date().toISOString() }), "invalid_contract"],
])("pull.json %s: lenovo null + lenovoError %s, the VPS still served", async (_label, content, code) => {
writeFile(statusPath, validStatus());
writeFile(pullPath, content);
const { result } = await withQuietErrors(() => read());
expect(result.status).toBe(200);
expect(result.body.lenovo).toBeNull();
expect(result.body.lenovoError).toBe(code);
expect(result.body.vps.expectedCount).toBe(2);
});
test("an unknown field in pull.json is not returned", async () => {
writeFile(pullPath, { ...validPull(), receivedAt: new Date().toISOString(), note: "hello" });
const { body } = await read();
expect(body.lenovo).not.toHaveProperty("note");
});
test("GET /backups writes nothing, even on a corrupt file", async () => {
writeFile(statusPath, "{corrupt");
writeFile(pullPath, "{corrupt");
await withQuietErrors(() => read());
expect(fs.readFileSync(statusPath, "utf-8")).toBe("{corrupt");
expect(fs.readdirSync(path.dirname(statusPath))).toEqual(["status.json"]);
expect(fs.readdirSync(path.dirname(pullPath))).toEqual(["pull.json"]);
});
});
describe("packaging", () => {
test("the Dockerfile COPY line ships backups.js", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copy = dockerfile.split("\n").find((line) => /^\s*COPY\s/.test(line));
expect(copy.trim().split(/\s+/)).toContain("backups.js");
});
});