feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s

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:
2026-06-04 11:23:42 +02:00
parent 05950ae0b6
commit fe863a588e
35 changed files with 3125 additions and 15 deletions

View File

@@ -0,0 +1,75 @@
/**
* Unit test for opt-in backup-bundle encryption
* (`src/lib/services/backup-destinations/bundle-encryption.ts`).
*
* Contract: AES-256-GCM streaming encryption with a scrypt-derived key.
* - round-trips arbitrary bytes (small + multi-chunk),
* - rejects the wrong passphrase (GCM auth failure),
* - rejects tampered ciphertext (GCM auth failure).
*/
import { createHash, randomBytes } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
decryptFileToFile,
encryptFileToFile,
} from '@/lib/services/backup-destinations/bundle-encryption';
function sha(p: string): string {
return createHash('sha256').update(readFileSync(p)).digest('hex');
}
describe('backup bundle encryption', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'pn-enc-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('round-trips a multi-chunk file with the correct passphrase', async () => {
const plain = path.join(dir, 'bundle.tar');
// ~400 KB of pseudo-random bytes → multiple cipher chunks.
writeFileSync(plain, randomBytes(400 * 1024));
const enc = path.join(dir, 'bundle.tar.enc');
const dec = path.join(dir, 'bundle.roundtrip.tar');
await encryptFileToFile(plain, enc, 'correct horse battery staple');
// Ciphertext must differ from plaintext and not be empty.
expect(statSync(enc).size).toBeGreaterThan(0);
expect(sha(enc)).not.toBe(sha(plain));
await decryptFileToFile(enc, dec, 'correct horse battery staple');
expect(sha(dec)).toBe(sha(plain));
});
it('rejects the wrong passphrase', async () => {
const plain = path.join(dir, 'b.tar');
writeFileSync(plain, Buffer.from('top secret contract bytes'));
const enc = path.join(dir, 'b.tar.enc');
await encryptFileToFile(plain, enc, 'right-pass');
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'WRONG-pass')).rejects.toThrow();
});
it('rejects tampered ciphertext', async () => {
const plain = path.join(dir, 'c.tar');
writeFileSync(plain, randomBytes(64 * 1024));
const enc = path.join(dir, 'c.tar.enc');
await encryptFileToFile(plain, enc, 'pw');
// Flip a byte in the middle of the ciphertext region.
const buf = readFileSync(enc);
const mid = Math.floor(buf.length / 2);
buf[mid] = (buf[mid] ?? 0) ^ 0xff;
writeFileSync(enc, buf);
await expect(decryptFileToFile(enc, path.join(dir, 'out.tar'), 'pw')).rejects.toThrow();
});
});