Files
pn-new-crm/tests/unit/services/pg-dump-runner.test.ts
Matt fe863a588e
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
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>
2026-06-04 11:23:42 +02:00

64 lines
2.2 KiB
TypeScript

/**
* Unit test for `runPgDump` in backup.service.ts.
*
* Regression: the dump promise must resolve once BOTH (a) the child process
* exits 0 and (b) the output file is fully flushed — regardless of which event
* fires first. The original implementation attached the file's `finish`
* listener *inside* the child `close` handler, but `stdout.pipe(out)`
* auto-ends the file when the child's stdout closes, so `finish` frequently
* fired before the listener was attached → the promise hung forever (observed
* end-to-end against a real pg_dump).
*
* We drive the spawn with `node` instead of `pg_dump` (injected via opts) so
* the test is deterministic and needs no database.
*/
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runPgDump } from '@/lib/services/backup.service';
describe('runPgDump', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'pn-pgdump-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('resolves and writes the child stdout to the output file on exit 0', async () => {
const out = path.join(dir, 'a.dump');
await runPgDump('ignored://url', out, {
command: process.execPath,
buildArgs: () => ['-e', 'process.stdout.write("DUMP-CONTENTS-1234")'],
});
expect(readFileSync(out, 'utf8')).toBe('DUMP-CONTENTS-1234');
});
it('rejects with the captured stderr when the child exits non-zero', async () => {
const out = path.join(dir, 'b.dump');
await expect(
runPgDump('ignored://url', out, {
command: process.execPath,
buildArgs: () => ['-e', 'process.stderr.write("kaboom"); process.exit(3)'],
}),
).rejects.toThrow(/exited 3.*kaboom/s);
});
it('rejects when the command cannot be spawned', async () => {
const out = path.join(dir, 'c.dump');
await expect(
runPgDump('ignored://url', out, {
command: '/nonexistent/definitely-not-a-real-binary-xyz',
buildArgs: () => [],
}),
).rejects.toBeTruthy();
});
});