Some checks failed
CI / verification (pull_request) Failing after 28s
POSIX sh agent run by a systemd user timer (Persistent=true, retries on failure): rsync over the classic sshd on 100.86.30.21:2222 with a pinned host key, .age files only, 30-day rotation that fails loudly, and a report to POST /backups/pull when the ingest token is configured (sent through a header file, never argv). Never decrypts. Refs maximus/la-compagnie-maximus#220 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
7.6 KiB
JavaScript
207 lines
7.6 KiB
JavaScript
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawnSync } = require("node:child_process");
|
|
|
|
// Coverage for the PostgreSQL backup pull agent (la-compagnie-maximus#220).
|
|
//
|
|
// The script runs under a systemd user timer and talks to the VPS over
|
|
// rsync/ssh, then reports to POST /backups/pull. Both are replaced here by fake
|
|
// binaries on PATH, which record their argv so the tests can check what would
|
|
// have crossed the wire — in particular that the ingest token never appears in
|
|
// a command line nor in the script's own output (journald keeps it forever).
|
|
|
|
const SCRIPT = path.join(__dirname, "..", "agent", "pg-backup-pull", "run-pg-backup-pull.sh");
|
|
const TOKEN = "ingest-token-0123456789abcdef";
|
|
const TODAY = "2026-10-11";
|
|
|
|
let tmp;
|
|
let bin;
|
|
|
|
function writeExecutable(file, content) {
|
|
fs.writeFileSync(file, content, { mode: 0o755 });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pg-backup-pull-"));
|
|
bin = path.join(tmp, "bin");
|
|
fs.mkdirSync(bin);
|
|
fs.writeFileSync(path.join(tmp, "key"), "fake key", { mode: 0o600 });
|
|
fs.writeFileSync(path.join(tmp, "known_hosts"), "[100.86.30.21]:2222 ssh-ed25519 AAAA");
|
|
|
|
// Fake rsync: records argv, then lands two .age files in today's directory.
|
|
writeExecutable(
|
|
path.join(bin, "rsync"),
|
|
`#!/bin/sh
|
|
printf '%s\\n' "$@" > "$FAKE_DIR/rsync.argv"
|
|
[ -n "\${FAKE_RSYNC_FAIL:-}" ] && exit 23
|
|
for last; do :; done
|
|
mkdir -p "$last/${TODAY}"
|
|
echo x > "$last/${TODAY}/maximus_api.dump.age"
|
|
echo x > "$last/${TODAY}/globals.sql.age"
|
|
`,
|
|
);
|
|
// Fake curl: records argv, the header file it was handed and the body.
|
|
writeExecutable(
|
|
path.join(bin, "curl"),
|
|
`#!/bin/sh
|
|
printf '%s\\n' "$@" > "$FAKE_DIR/curl.argv"
|
|
prev=""
|
|
for a; do
|
|
case "$prev" in -H) case "$a" in @*) cp "\${a#@}" "$FAKE_DIR/curl.header" ;; esac ;; esac
|
|
prev="$a"
|
|
done
|
|
cat > "$FAKE_DIR/curl.body"
|
|
printf '%s' "\${FAKE_CURL_STATUS:-204}"
|
|
`,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
function run({ envFile, extraEnv = {} } = {}) {
|
|
const dest = path.join(tmp, "dest");
|
|
const result = spawnSync("sh", [SCRIPT], {
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: `${bin}:/usr/bin:/bin`,
|
|
HOME: tmp,
|
|
FAKE_DIR: tmp,
|
|
PG_BACKUP_PULL_ENV_FILE: envFile ?? path.join(tmp, "absent.env"),
|
|
PG_BACKUP_PULL_DEST: dest,
|
|
PG_BACKUP_PULL_STATE_DIR: path.join(tmp, "state"),
|
|
PG_BACKUP_PULL_SSH_KEY: path.join(tmp, "key"),
|
|
PG_BACKUP_PULL_KNOWN_HOSTS: path.join(tmp, "known_hosts"),
|
|
PG_BACKUP_PULL_TODAY: TODAY,
|
|
...extraEnv,
|
|
},
|
|
});
|
|
return { ...result, output: result.stdout + result.stderr, dest };
|
|
}
|
|
|
|
function writeEnv() {
|
|
const file = path.join(tmp, "agent.env");
|
|
fs.writeFileSync(
|
|
file,
|
|
`BACKUPS_API_URL=https://health.example.test\nBACKUPS_INGEST_TOKEN=${TOKEN}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return file;
|
|
}
|
|
|
|
const read = (name) => fs.readFileSync(path.join(tmp, name), "utf8");
|
|
|
|
describe("pull", () => {
|
|
test("pulls over port 2222 with a pinned host key, .age files only, never --delete", () => {
|
|
const { status } = run();
|
|
expect(status).toBe(0);
|
|
const argv = read("rsync.argv").split("\n");
|
|
expect(argv).toContain("--include=*.age");
|
|
expect(argv).toContain("--exclude=*");
|
|
expect(argv.indexOf("--include=*.age")).toBeLessThan(argv.indexOf("--exclude=*"));
|
|
expect(argv).not.toContain("--delete");
|
|
expect(argv).toContain("pgbackup@100.86.30.21:");
|
|
const ssh = argv.find((a) => a.startsWith("ssh "));
|
|
expect(ssh).toContain("-p 2222");
|
|
expect(ssh).toContain("StrictHostKeyChecking=yes");
|
|
expect(ssh).toContain("BatchMode=yes");
|
|
expect(ssh).toContain("IdentitiesOnly=yes");
|
|
});
|
|
|
|
test("without a token, still pulls and exits 0, saying the report was skipped", () => {
|
|
const { status, output, dest } = run();
|
|
expect(status).toBe(0);
|
|
expect(output).toContain("report skipped");
|
|
expect(fs.existsSync(path.join(dest, TODAY, "maximus_api.dump.age"))).toBe(true);
|
|
expect(fs.existsSync(path.join(tmp, "curl.argv"))).toBe(false);
|
|
});
|
|
|
|
test("keeps the destination private", () => {
|
|
const { dest } = run();
|
|
expect(fs.statSync(dest).mode & 0o777).toBe(0o700);
|
|
expect(fs.statSync(path.join(dest, TODAY, "globals.sql.age")).mode & 0o077).toBe(0);
|
|
});
|
|
|
|
test("a failed pull exits 2 and reports pull_failed", () => {
|
|
const { status } = run({ envFile: writeEnv(), extraEnv: { FAKE_RSYNC_FAIL: "1" } });
|
|
expect(status).toBe(2);
|
|
expect(JSON.parse(read("curl.body"))).toMatchObject({ ok: false, files: 0, error: "pull_failed" });
|
|
});
|
|
|
|
test("missing ssh key is an install problem: exit 1, nothing pulled", () => {
|
|
fs.rmSync(path.join(tmp, "key"));
|
|
const { status } = run();
|
|
expect(status).toBe(1);
|
|
expect(fs.existsSync(path.join(tmp, "rsync.argv"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("report", () => {
|
|
test("posts pulledAt, files and ok to /backups/pull", () => {
|
|
const { status } = run({ envFile: writeEnv() });
|
|
expect(status).toBe(0);
|
|
const body = JSON.parse(read("curl.body"));
|
|
expect(Object.keys(body).sort()).toEqual(["files", "ok", "pulledAt"]);
|
|
expect(body).toMatchObject({ ok: true, files: 2 });
|
|
expect(body.pulledAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
|
expect(read("curl.argv").split("\n")).toContain("https://health.example.test/backups/pull");
|
|
});
|
|
|
|
test("the token travels in a header file, never in argv nor in the output", () => {
|
|
const { output } = run({ envFile: writeEnv() });
|
|
expect(read("curl.header")).toBe(`Authorization: Bearer ${TOKEN}\n`);
|
|
expect(read("curl.argv")).not.toContain(TOKEN.slice(0, 12));
|
|
expect(output).not.toContain(TOKEN.slice(0, 12));
|
|
expect(fs.readdirSync(path.join(tmp, "state"))).not.toContain("auth.header");
|
|
});
|
|
|
|
test("a non-2xx answer exits 3", () => {
|
|
const { status, output } = run({ envFile: writeEnv(), extraEnv: { FAKE_CURL_STATUS: "401" } });
|
|
expect(status).toBe(3);
|
|
expect(output).toContain("report failed status=401");
|
|
});
|
|
});
|
|
|
|
describe("rotation", () => {
|
|
test("keeps 30 dated directories, today included, and ignores other names", () => {
|
|
const dest = path.join(tmp, "dest");
|
|
for (const d of ["2026-09-11", "2026-09-12", "notes"]) fs.mkdirSync(path.join(dest, d), { recursive: true });
|
|
const { status } = run();
|
|
expect(status).toBe(0);
|
|
expect(fs.existsSync(path.join(dest, "2026-09-11"))).toBe(false);
|
|
expect(fs.existsSync(path.join(dest, "2026-09-12"))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, "notes"))).toBe(true);
|
|
});
|
|
|
|
test("a directory that cannot be removed fails loudly and reports rotation_failed", () => {
|
|
const dest = path.join(tmp, "dest");
|
|
const old = path.join(dest, "2026-09-01");
|
|
fs.mkdirSync(path.join(old, "locked"), { recursive: true });
|
|
fs.writeFileSync(path.join(old, "locked", "f.age"), "x");
|
|
fs.chmodSync(path.join(old, "locked"), 0o500);
|
|
try {
|
|
const { status } = run({ envFile: writeEnv() });
|
|
expect(status).toBe(1);
|
|
expect(JSON.parse(read("curl.body"))).toMatchObject({ ok: false, error: "rotation_failed" });
|
|
} finally {
|
|
fs.chmodSync(path.join(old, "locked"), 0o700);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the script itself", () => {
|
|
test("never enables shell tracing — `set -x` would echo the token into journald", () => {
|
|
const traced = fs
|
|
.readFileSync(SCRIPT, "utf8")
|
|
.split("\n")
|
|
.filter((line) => !line.trim().startsWith("#"))
|
|
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
|
|
expect(traced).toEqual([]);
|
|
});
|
|
|
|
test("is executable", () => {
|
|
expect(fs.statSync(SCRIPT).mode & 0o111).toBeGreaterThan(0);
|
|
});
|
|
});
|