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:
63
tests/unit/services/backup-transport-factory.test.ts
Normal file
63
tests/unit/services/backup-transport-factory.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Unit test for the transport factory + S3 endpoint parser
|
||||
* (`src/lib/services/backup-destinations/`).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTransport,
|
||||
FilesystemTransport,
|
||||
parseS3Endpoint,
|
||||
S3Transport,
|
||||
SftpTransport,
|
||||
} from '@/lib/services/backup-destinations';
|
||||
|
||||
describe('buildTransport', () => {
|
||||
it('builds the right transport per type', () => {
|
||||
expect(buildTransport('filesystem', { directory: '/x' })).toBeInstanceOf(FilesystemTransport);
|
||||
expect(buildTransport('sftp', { host: 'h', username: 'u', remoteDir: '/d' })).toBeInstanceOf(
|
||||
SftpTransport,
|
||||
);
|
||||
expect(
|
||||
buildTransport('s3', { endpoint: 'h', bucket: 'b', accessKey: 'a', secretKey: 's' }),
|
||||
).toBeInstanceOf(S3Transport);
|
||||
});
|
||||
|
||||
it('throws on an unknown type', () => {
|
||||
// @ts-expect-error testing the runtime guard
|
||||
expect(() => buildTransport('ftp', {})).toThrow(/Unknown backup destination type/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseS3Endpoint', () => {
|
||||
it('parses an https URL into host + ssl', () => {
|
||||
expect(parseS3Endpoint('https://s3.eu-central.example.com', {})).toEqual({
|
||||
endPoint: 's3.eu-central.example.com',
|
||||
useSSL: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an http URL with a port', () => {
|
||||
expect(parseS3Endpoint('http://localhost:9000', {})).toEqual({
|
||||
endPoint: 'localhost',
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a bare host as ssl-by-default', () => {
|
||||
expect(parseS3Endpoint('s3.amazonaws.com', {})).toEqual({
|
||||
endPoint: 's3.amazonaws.com',
|
||||
useSSL: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours explicit useSSL=false on a bare host', () => {
|
||||
expect(parseS3Endpoint('minio.internal', { useSSL: false, port: 9000 })).toEqual({
|
||||
endPoint: 'minio.internal',
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user