Files
pn-new-crm/tests/unit/services/backup-destinations-service.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

87 lines
2.8 KiB
TypeScript

/**
* Unit tests for the pure helpers of the backup-destinations service:
* schedule-due logic + secret config (serialize → encrypt at rest →
* decrypt for use, mask for API). DB-backed CRUD is covered by the e2e
* verification, not here.
*/
import { describe, expect, it } from 'vitest';
import {
decryptConfig,
isScheduleDue,
maskConfig,
serializeConfig,
} from '@/lib/services/backup-destinations.service';
describe('isScheduleDue', () => {
// 2026-06-07 is a Sunday; 2026-06-08 a Monday.
const sunday = new Date('2026-06-07T02:00:00Z');
const monday = new Date('2026-06-08T02:00:00Z');
it('off is never due', () => {
expect(isScheduleDue('off', sunday)).toBe(false);
expect(isScheduleDue('off', monday)).toBe(false);
});
it('daily is always due', () => {
expect(isScheduleDue('daily', sunday)).toBe(true);
expect(isScheduleDue('daily', monday)).toBe(true);
});
it('weekly is due only on Sunday', () => {
expect(isScheduleDue('weekly', sunday)).toBe(true);
expect(isScheduleDue('weekly', monday)).toBe(false);
});
});
describe('secret config handling', () => {
it('serialize encrypts secrets, decrypt restores them, mask hides them', () => {
const incoming = {
host: 'box.example.com',
username: 'crm',
password: 'hunter2',
remoteDir: '/backups',
};
const stored = serializeConfig('sftp', incoming);
// Stored password must not be the plaintext.
expect(stored.password).not.toBe('hunter2');
expect(stored.host).toBe('box.example.com');
// Decrypt restores the plaintext for transport use.
expect(decryptConfig('sftp', stored)).toMatchObject({
host: 'box.example.com',
username: 'crm',
password: 'hunter2',
remoteDir: '/backups',
});
// Mask hides the secret and exposes only a *IsSet marker.
const masked = maskConfig('sftp', stored);
expect(masked.password).toBeUndefined();
expect(masked.passwordIsSet).toBe(true);
expect(masked.host).toBe('box.example.com');
});
it('update with a blank secret keeps the existing encrypted value', () => {
const original = serializeConfig('s3', {
endpoint: 'https://s3.example.com',
bucket: 'b',
accessKey: 'AK',
secretKey: 'SUPERSECRET',
});
// Admin edits the bucket but leaves the secret key blank (unchanged).
const updated = serializeConfig(
's3',
{ endpoint: 'https://s3.example.com', bucket: 'b2', accessKey: 'AK', secretKey: '' },
original,
);
expect(updated.bucket).toBe('b2');
expect(decryptConfig('s3', updated).secretKey).toBe('SUPERSECRET');
});
it('filesystem has no secrets to mask', () => {
const stored = serializeConfig('filesystem', { directory: '/mnt/nas/backups' });
expect(maskConfig('filesystem', stored)).toEqual({ directory: '/mnt/nas/backups' });
});
});