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>
209 lines
8.0 KiB
TypeScript
209 lines
8.0 KiB
TypeScript
/**
|
|
* Unit test for the full-bundle backup tar assembler
|
|
* (`assembleBackupTar` in `src/lib/services/backup-export.service.ts`).
|
|
*
|
|
* Phase 4a of docs/storage-migration-and-backup-plan.md: assemble a single
|
|
* tar containing `db.dump` (a pre-produced pg_dump file) + `blobs/<key>` for
|
|
* every blob-bearing row + a `manifest.json` describing the bundle with a
|
|
* sha256 per object so a restore can verify integrity.
|
|
*
|
|
* Uses an in-memory storage backend (no MinIO) and a synthetic dump file
|
|
* (no pg_dump). The produced tar is read back with the system `tar` CLI so we
|
|
* assert against the real archive bytes, not archiver internals.
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { assembleBackupTar } from '@/lib/services/backup-export.service';
|
|
import type { PresignOpts, PutOpts, StorageBackend } from '@/lib/storage';
|
|
|
|
class InMemoryBackend implements StorageBackend {
|
|
readonly name = 's3' as const;
|
|
readonly store = new Map<string, { body: Buffer; contentType: string }>();
|
|
|
|
async put(
|
|
key: string,
|
|
body: Buffer | NodeJS.ReadableStream,
|
|
opts: PutOpts,
|
|
): Promise<{ key: string; sizeBytes: number; sha256: string }> {
|
|
const buffer = Buffer.isBuffer(body) ? body : await streamToBuffer(body);
|
|
this.store.set(key, { body: buffer, contentType: opts.contentType });
|
|
return {
|
|
key,
|
|
sizeBytes: buffer.length,
|
|
sha256: createHash('sha256').update(buffer).digest('hex'),
|
|
};
|
|
}
|
|
|
|
async get(key: string): Promise<NodeJS.ReadableStream> {
|
|
const r = this.store.get(key);
|
|
if (!r) throw new Error(`not found: ${key}`);
|
|
// Chunk the body so multi-chunk streaming is exercised.
|
|
const chunks: Buffer[] = [];
|
|
for (let i = 0; i < r.body.length; i += 64 * 1024) {
|
|
chunks.push(r.body.subarray(i, i + 64 * 1024));
|
|
}
|
|
return Readable.from(chunks.length ? chunks : [Buffer.alloc(0)]);
|
|
}
|
|
|
|
async head(key: string) {
|
|
const r = this.store.get(key);
|
|
if (!r) return null;
|
|
return { sizeBytes: r.body.length, contentType: r.contentType };
|
|
}
|
|
|
|
async delete(key: string): Promise<void> {
|
|
this.store.delete(key);
|
|
}
|
|
|
|
async listByPrefix(prefix: string): Promise<string[]> {
|
|
return [...this.store.keys()].filter((k) => k.startsWith(prefix));
|
|
}
|
|
|
|
async presignUpload(_key: string, _opts: PresignOpts) {
|
|
return { url: 'mem://upload', method: 'PUT' as const };
|
|
}
|
|
|
|
async presignDownload(_key: string, _opts: PresignOpts) {
|
|
return { url: 'mem://download', expiresAt: new Date(Date.now() + 1000) };
|
|
}
|
|
}
|
|
|
|
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function sha256(buf: Buffer): string {
|
|
return createHash('sha256').update(buf).digest('hex');
|
|
}
|
|
|
|
describe('assembleBackupTar', () => {
|
|
let workDir: string;
|
|
|
|
beforeEach(() => {
|
|
workDir = mkdtempSync(path.join(tmpdir(), 'pn-backup-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('bundles the db dump + every blob with a verifiable manifest', async () => {
|
|
const backend = new InMemoryBackend();
|
|
|
|
// A small blob and a larger multi-chunk blob (exercises streaming).
|
|
const smallBody = Buffer.from('the quick brown fox');
|
|
const bigBody = Buffer.alloc(300 * 1024);
|
|
for (let i = 0; i < bigBody.length; i++) bigBody[i] = (i * 7) % 256;
|
|
await backend.put('port-nimara/files/a.bin', smallBody, { contentType: 'application/pdf' });
|
|
await backend.put('port-nimara/berths/big.pdf', bigBody, { contentType: 'application/pdf' });
|
|
|
|
// Synthetic pg_dump file.
|
|
const dumpBody = Buffer.from('PGDMP-FAKE-CUSTOM-FORMAT-CONTENTS\n'.repeat(1000));
|
|
const dumpPath = path.join(workDir, 'db.dump');
|
|
writeFileSync(dumpPath, dumpBody);
|
|
|
|
const outPath = path.join(workDir, 'bundle.tar');
|
|
const now = new Date('2026-06-04T12:00:00.000Z');
|
|
|
|
const manifest = await assembleBackupTar({
|
|
backend,
|
|
dumpFilePath: dumpPath,
|
|
blobRefs: [
|
|
{ tableName: 'files', pk: 'f1', key: 'port-nimara/files/a.bin' },
|
|
{ tableName: 'berth_pdf_versions', pk: 'b1', key: 'port-nimara/berths/big.pdf' },
|
|
],
|
|
outFilePath: outPath,
|
|
storageBackendName: 's3',
|
|
now,
|
|
});
|
|
|
|
// ── manifest shape ──────────────────────────────────────────────────────
|
|
expect(manifest.formatVersion).toBe(1);
|
|
expect(manifest.createdAt).toBe('2026-06-04T12:00:00.000Z');
|
|
expect(manifest.storageBackend).toBe('s3');
|
|
expect(manifest.database.file).toBe('db.dump');
|
|
expect(manifest.database.sizeBytes).toBe(dumpBody.length);
|
|
expect(manifest.database.sha256).toBe(sha256(dumpBody));
|
|
expect(manifest.counts.blobs).toBe(2);
|
|
expect(manifest.counts.blobBytes).toBe(smallBody.length + bigBody.length);
|
|
expect(manifest.counts.skipped).toBe(0);
|
|
|
|
const smallEntry = manifest.blobs.find((b) => b.key === 'port-nimara/files/a.bin');
|
|
expect(smallEntry).toMatchObject({
|
|
table: 'files',
|
|
pk: 'f1',
|
|
sizeBytes: smallBody.length,
|
|
sha256: sha256(smallBody),
|
|
});
|
|
|
|
// ── extract the real tar and verify bytes ────────────────────────────────
|
|
const extractDir = path.join(workDir, 'extract');
|
|
execFileSync('mkdir', ['-p', extractDir]);
|
|
execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
|
|
|
|
const extractedDump = readFileSync(path.join(extractDir, 'db.dump'));
|
|
expect(extractedDump.equals(dumpBody)).toBe(true);
|
|
|
|
const extractedSmall = readFileSync(path.join(extractDir, 'blobs/port-nimara/files/a.bin'));
|
|
expect(extractedSmall.equals(smallBody)).toBe(true);
|
|
|
|
const extractedBig = readFileSync(path.join(extractDir, 'blobs/port-nimara/berths/big.pdf'));
|
|
expect(extractedBig.equals(bigBody)).toBe(true);
|
|
|
|
// The on-disk manifest matches the returned one and verifies the bytes.
|
|
const onDiskManifest = JSON.parse(readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
|
|
expect(onDiskManifest).toEqual(manifest);
|
|
for (const entry of manifest.blobs) {
|
|
const bytes = readFileSync(path.join(extractDir, 'blobs', entry.key));
|
|
expect(sha256(bytes)).toBe(entry.sha256);
|
|
expect(bytes.length).toBe(entry.sizeBytes);
|
|
}
|
|
});
|
|
|
|
it('records referenced-but-missing blobs as skipped instead of failing', async () => {
|
|
const backend = new InMemoryBackend();
|
|
await backend.put('port-nimara/files/present.bin', Buffer.from('here'), {
|
|
contentType: 'application/octet-stream',
|
|
});
|
|
|
|
const dumpPath = path.join(workDir, 'db.dump');
|
|
writeFileSync(dumpPath, Buffer.from('dump'));
|
|
const outPath = path.join(workDir, 'bundle.tar');
|
|
|
|
const manifest = await assembleBackupTar({
|
|
backend,
|
|
dumpFilePath: dumpPath,
|
|
blobRefs: [
|
|
{ tableName: 'files', pk: 'f1', key: 'port-nimara/files/present.bin' },
|
|
{ tableName: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' },
|
|
],
|
|
outFilePath: outPath,
|
|
storageBackendName: 's3',
|
|
now: new Date('2026-06-04T12:00:00.000Z'),
|
|
});
|
|
|
|
expect(manifest.counts.blobs).toBe(1);
|
|
expect(manifest.counts.skipped).toBe(1);
|
|
expect(manifest.skipped).toEqual([
|
|
expect.objectContaining({ table: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' }),
|
|
]);
|
|
|
|
// The missing blob must NOT appear in the archive.
|
|
const extractDir = path.join(workDir, 'extract');
|
|
execFileSync('mkdir', ['-p', extractDir]);
|
|
execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
|
|
const blobFiles = readdirSync(path.join(extractDir, 'blobs', 'port-nimara', 'files'));
|
|
expect(blobFiles).toEqual(['present.bin']);
|
|
});
|
|
});
|