dorfteich/apps/backup/src/runner.test.ts
Claude Fable 5 8dbff86537
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 17:29:15 +02:00

140 lines
4.7 KiB
TypeScript

import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { archiveFileName, dumpFileName } from './backup-set.js';
import { runBackup, type RunnerDeps } from './runner.js';
import { readStatus } from './status.js';
/**
* Time-accelerated nightly runs (issue #83 acceptance criteria): the clock
* is injected, so "a month of nights" is a loop, with real files in a temp
* backups directory and dump/archive doubles standing in for pg_dump/tar
* (their real counterparts are covered by pg.ts/archive.ts on the stage).
*/
const DAY = 24 * 60 * 60 * 1000;
const START = new Date('2026-07-01T03:00:00Z').getTime();
let dir: string;
let clock: Date;
let failures: Array<{ backupId: string; error: string; lastSuccessAt: string | null }>;
function makeDeps(overrides: Partial<RunnerDeps> = {}): RunnerDeps {
return {
backupsDir: dir,
retentionDays: 7,
now: () => new Date(clock),
dump: (outFile) => writeFile(outFile, 'dump-bytes'),
archive: (outFile) => writeFile(outFile, 'archive-bytes!'),
onFailure: async (run) => {
failures.push(run);
},
log: { info: () => {}, error: () => {} },
...overrides,
};
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dorfteich-backup-'));
clock = new Date(START);
failures = [];
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe('runBackup', () => {
it('produces a dump, a matching archive, and status.json', async () => {
const status = await runBackup(makeDeps());
const names = await readdir(dir);
expect(names).toContain(dumpFileName('20260701-030000'));
expect(names).toContain(archiveFileName('20260701-030000'));
expect(status.lastRun.outcome).toBe('succeeded');
expect(status.lastRun.sizes).toEqual({ dumpBytes: 10, archiveBytes: 14 });
expect(status.lastSuccess?.backupId).toBe('20260701-030000');
expect(readStatus(dir)).toEqual(status);
});
it('keeps exactly the retention window across simulated nights', async () => {
for (let night = 0; night < 30; night += 1) {
clock = new Date(START + night * DAY);
await runBackup(makeDeps());
}
const names = (await readdir(dir)).filter((name) => name.startsWith('db-'));
// Retention 7 days: the last 7 nights survive plus the current night's set.
expect(names.length).toBe(8);
expect(names).toContain(dumpFileName('20260730-030000'));
expect(names).not.toContain(dumpFileName('20260722-030000'));
expect(names).toContain(dumpFileName('20260723-030000'));
});
it('records a failed run, keeps lastSuccess, alerts, and cleans partials', async () => {
await runBackup(makeDeps());
clock = new Date(START + DAY);
const status = await runBackup(
makeDeps({
dump: async () => {
throw new Error('connection refused');
},
}),
);
expect(status.lastRun.outcome).toBe('failed');
expect(status.lastRun.error).toContain('connection refused');
expect(status.lastSuccess?.backupId).toBe('20260701-030000');
expect(failures).toHaveLength(1);
expect(failures[0]?.lastSuccessAt).toBe(new Date(START).toISOString());
const names = await readdir(dir);
expect(names.filter((name) => name.endsWith('.partial'))).toHaveLength(0);
// The last good set survives a failed night.
expect(names).toContain(dumpFileName('20260701-030000'));
});
it('a failure alert that itself fails never breaks the run', async () => {
const status = await runBackup(
makeDeps({
dump: async () => {
throw new Error('disk full');
},
onFailure: async () => {
throw new Error('smtp down');
},
}),
);
expect(status.lastRun.outcome).toBe('failed');
expect(readStatus(dir)?.lastRun.error).toContain('disk full');
});
it('after long downtime the newest complete set survives pruning', async () => {
await runBackup(makeDeps());
// 60 days later (retention 7): the old set is expired but must survive
// as the newest complete one until a new success replaces it.
clock = new Date(START + 60 * DAY);
const status = await runBackup(
makeDeps({
dump: async () => {
throw new Error('db gone');
},
}),
);
expect(status.lastRun.outcome).toBe('failed');
expect(await readdir(dir)).toContain(dumpFileName('20260701-030000'));
// The next success prunes it.
clock = new Date(START + 61 * DAY);
await runBackup(makeDeps());
const names = await readdir(dir);
expect(names).not.toContain(dumpFileName('20260701-030000'));
expect(names).toContain(dumpFileName('20260831-030000'));
});
});