feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current storage backend (no storage cutover required): - Full-bundle export: db.dump (pg_dump custom) + every storage blob + manifest.json with per-object SHA-256, streamed as a tar. Entry points: admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts. - Admin-configurable push destinations (backup_destinations table, migration 0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM at rest; API returns only *IsSet markers. - Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) + scripts/decrypt-backup.ts for restore. - Wired the previously-dead database-backup cron to runScheduledBackupPush (push to enabled destinations, prune to retention, alert super-admins on failure). Tests: 1608 unit/integration pass; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
72
tests/unit/services/backup-filesystem-transport.test.ts
Normal file
72
tests/unit/services/backup-filesystem-transport.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Unit test for the filesystem (mounted-path / NAS) backup transport
|
||||
* (`src/lib/services/backup-destinations/filesystem.ts`).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { FilesystemTransport } from '@/lib/services/backup-destinations/filesystem';
|
||||
import { BACKUP_NAME_PREFIX } from '@/lib/services/backup-destinations/types';
|
||||
|
||||
describe('FilesystemTransport', () => {
|
||||
let work: string;
|
||||
let destDir: string;
|
||||
beforeEach(() => {
|
||||
work = mkdtempSync(path.join(tmpdir(), 'pn-fstr-'));
|
||||
destDir = path.join(work, 'backups');
|
||||
mkdirSync(destDir);
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test() rejects when the directory does not exist', async () => {
|
||||
const t = new FilesystemTransport({ directory: path.join(work, 'nope') });
|
||||
await expect(t.test()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('push() copies the bundle into the destination directory', async () => {
|
||||
const local = path.join(work, `${BACKUP_NAME_PREFIX}2026-06-04.tar`);
|
||||
writeFileSync(local, Buffer.from('BUNDLE-BYTES'));
|
||||
const t = new FilesystemTransport({ directory: destDir });
|
||||
|
||||
const res = await t.push(local, `${BACKUP_NAME_PREFIX}2026-06-04.tar`);
|
||||
|
||||
expect(res.bytes).toBe('BUNDLE-BYTES'.length);
|
||||
const landed = readFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-04.tar`), 'utf8');
|
||||
expect(landed).toBe('BUNDLE-BYTES');
|
||||
});
|
||||
|
||||
it('prune() keeps the N newest bundles and ignores unrelated files', async () => {
|
||||
// Five backups (timestamp-in-name sorts chronologically) + an unrelated file.
|
||||
for (const d of ['01', '02', '03', '04', '05']) {
|
||||
writeFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-${d}.tar`), 'x');
|
||||
}
|
||||
writeFileSync(path.join(destDir, 'unrelated-keepme.txt'), 'y');
|
||||
|
||||
const t = new FilesystemTransport({ directory: destDir });
|
||||
const { deleted } = await t.prune(2);
|
||||
|
||||
expect(deleted).toBe(3);
|
||||
const remaining = readdirSync(destDir).sort();
|
||||
expect(remaining).toEqual([
|
||||
`${BACKUP_NAME_PREFIX}2026-06-04.tar`,
|
||||
`${BACKUP_NAME_PREFIX}2026-06-05.tar`,
|
||||
'unrelated-keepme.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prune(null) keeps everything', async () => {
|
||||
for (const d of ['01', '02', '03']) {
|
||||
writeFileSync(path.join(destDir, `${BACKUP_NAME_PREFIX}2026-06-${d}.tar`), 'x');
|
||||
}
|
||||
const t = new FilesystemTransport({ directory: destDir });
|
||||
const { deleted } = await t.prune(null);
|
||||
expect(deleted).toBe(0);
|
||||
expect(readdirSync(destDir).length).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user