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>
766 lines
32 KiB
JavaScript
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");
|
|
});
|
|
});
|