/** * 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/` 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(); 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 { 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 { this.store.delete(key); } async listByPrefix(prefix: string): Promise { 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 { 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']); }); });