/** * Retention resolves per dimension, and an unset dimension is UNBOUNDED. * * The trap this file exists for: `backup.retention` is an optional block, so a * manifest that omits it prunes nothing at all — its inner `count: 7` / * `max_age_days: 30` defaults never apply. If setting one dimension let the * other fall back to those defaults, an operator asking to keep 3 copies would * silently arm a 30-day deletion on a module that had been keeping everything. * Every other failure here is recoverable; that one destroys backups. */ import { describe, expect, test } from 'bun:test'; import type { Backup } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { BACKUP_RETENTION_COUNT_CONFIG_KEY, BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY, effectiveBackupRetention, identifyExpiredBackups, prunesNothing, } from './backup-retention'; const DAY = 24 * 60 * 60 * 1000; const UNBOUNDED = Number.POSITIVE_INFINITY; function manifestWith(retention?: { count?: number; max_age_days?: number }): ModuleManifest { return { backup: retention ? { retention } : undefined } as unknown as ModuleManifest; } /** Newest first, matching what `listCompletedBackupsForModule` returns. */ function backupsAgedDays(...ages: number[]): Backup[] { return ages.map( (days, i) => ({ id: `backup-${i}`, storagePath: `path-${i}`, storageId: 'storage-1', startedAt: new Date(Date.now() - days * DAY), }) as unknown as Backup, ); } describe('effectiveBackupRetention', () => { test('nothing declared and nothing set is unbounded in both dimensions', () => { const policy = effectiveBackupRetention(manifestWith(), {}); expect(policy).toEqual({ count: UNBOUNDED, maxAgeDays: UNBOUNDED }); expect(prunesNothing(policy)).toBe(true); }); // THE data-loss trap. Setting one dimension must not arm the other. test('setting one dimension leaves the other unbounded when the manifest declares none', () => { const policy = effectiveBackupRetention(manifestWith(), { [BACKUP_RETENTION_COUNT_CONFIG_KEY]: 3, }); expect(policy.count).toBe(3); expect(policy.maxAgeDays).toBe(UNBOUNDED); expect(prunesNothing(policy)).toBe(false); }); test('an override bounds one dimension while the manifest bounds the other', () => { const policy = effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), { [BACKUP_RETENTION_COUNT_CONFIG_KEY]: 3, }); expect(policy).toEqual({ count: 3, maxAgeDays: 30 }); }); test('both dimensions can be overridden independently', () => { const policy = effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), { [BACKUP_RETENTION_COUNT_CONFIG_KEY]: 2, [BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY]: 90, }); expect(policy).toEqual({ count: 2, maxAgeDays: 90 }); }); test("the manifest's suggestion applies when nobody has overridden", () => { expect(effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), {})).toEqual({ count: 7, maxAgeDays: 30, }); }); test('a non-positive or unparseable override falls back rather than deleting everything', () => { // Validated at SET time, so this is hand-edited state. A `0` read as "keep // zero copies" would delete every backup the module has. for (const bad of [0, -1, 'lots', 2.5]) { const policy = effectiveBackupRetention(manifestWith({ count: 7, max_age_days: 30 }), { [BACKUP_RETENTION_COUNT_CONFIG_KEY]: bad, }); expect(policy.count).toBe(7); } }); }); describe('identifyExpiredBackups with unbounded dimensions', () => { test('an unbounded count never prunes on count', () => { const expired = identifyExpiredBackups(backupsAgedDays(1, 2, 3, 4, 5), { count: UNBOUNDED, maxAgeDays: UNBOUNDED, }); expect(expired).toEqual([]); }); test('a bounded count prunes past it, and age alone prunes nothing', () => { const expired = identifyExpiredBackups(backupsAgedDays(1, 2, 100, 200), { count: 3, maxAgeDays: UNBOUNDED, }); expect(expired.map((b) => b.id)).toEqual(['backup-3']); }); test('a bounded age prunes past it while every copy is kept on count', () => { const expired = identifyExpiredBackups(backupsAgedDays(1, 2, 100), { count: UNBOUNDED, maxAgeDays: 30, }); expect(expired.map((b) => b.id)).toEqual(['backup-2']); }); });