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>
87 lines
2.8 KiB
TypeScript
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' });
|
|
});
|
|
});
|