import { describe, expect, test } from 'bun:test'; import type { ModuleManifest } from '../../manifest/schema'; import { parseCadence } from '../cadence'; import { type InstalledModuleBackupInfo, auditBackups, backupStaleThresholdMs } from './backups'; const NOW = new Date('2026-04-25T00:00:00Z').getTime(); const HOUR = 60 * 60 * 1000; const ONE_DAY = 24 * HOUR; function makeModule( id: string, opts: { hasBackupHook: boolean; lastSuccessfulBackupAt: number | null; schedule?: string; scheduleOverride?: string; }, ): InstalledModuleBackupInfo { const manifest = { id, name: id, version: '1.0.0', celilo_contract: '1.0', hooks: opts.hasBackupHook ? { on_backup: { script: './backup.ts' } } : {}, backup: opts.schedule ? { schedule: opts.schedule } : undefined, } as unknown as ModuleManifest; // Default to INSTALLED — existing tests assert backup findings // fire, which is the deployed-module behavior. Tests for the // non-deployed-skip behavior override this explicitly. return { id, state: 'INSTALLED', manifest, scheduleOverride: opts.scheduleOverride, lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt, }; } describe('backupStaleThresholdMs', () => { function cadence(value: string) { const parsed = parseCadence(value); if (parsed === null) throw new Error(`test fixture is not a cadence: ${value}`); return parsed; } // The four values the change's release note promises. A table could not // answer for `6h`, so these come out of `cadence + max(1h, cadence × 0.1)`; // `weekly` is the only one that now alerts EARLIER than it used to (8d). test('the four documented thresholds', () => { expect(backupStaleThresholdMs(cadence('hourly'))).toBe(2 * HOUR); expect(backupStaleThresholdMs(cadence('daily'))).toBe(26.4 * HOUR); expect(backupStaleThresholdMs(cadence('weekly'))).toBe(7.7 * ONE_DAY); expect(backupStaleThresholdMs(cadence('monthly'))).toBe(33 * ONE_DAY); }); test('a custom duration has a defined threshold', () => { expect(backupStaleThresholdMs(cadence('6h'))).toBe(7 * HOUR); expect(backupStaleThresholdMs(cadence('90m'))).toBe(90 * 60_000 + HOUR); }); test('manual has none — an opted-out module is never stale', () => { expect(backupStaleThresholdMs('manual')).toBeNull(); }); }); describe('auditBackups', () => { test('skips modules without an on_backup hook', async () => { const result = await auditBackups({ modules: [makeModule('caddy', { hasBackupHook: false, lastSuccessfulBackupAt: null })], now: () => NOW, }); expect(result).toEqual([]); }); // A module opted out of scheduled backups used to be reported as missing one // forever: the never-backed-up check fired BEFORE the manual check, and the // only remediation offered was the very thing the operator declined. test('a module opted out is not reported as missing a backup', async () => { const result = await auditBackups({ modules: [ makeModule('lunacycle', { hasBackupHook: true, lastSuccessfulBackupAt: null, schedule: 'manual', }), ], now: () => NOW, }); expect(result).toEqual([]); }); test('a module that has NOT opted out is still reported as missing a backup', async () => { const result = await auditBackups({ modules: [ makeModule('authentik', { hasBackupHook: true, lastSuccessfulBackupAt: null, schedule: 'daily', }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ category: 'backups', severity: 'drift', code: 'backup_missing', subject: 'authentik', }); }); test("an operator's manual override silences a module the manifest wanted backed up", async () => { const result = await auditBackups({ modules: [ makeModule('authentik', { hasBackupHook: true, lastSuccessfulBackupAt: null, schedule: 'daily', scheduleOverride: 'manual', }), ], now: () => NOW, }); expect(result).toEqual([]); }); test('staleness is judged against the override, not the suggestion', async () => { // Manifest says weekly (fresh at 2 days); the operator asked for hourly. const result = await auditBackups({ modules: [ makeModule('caddy', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 2 * ONE_DAY, schedule: 'weekly', scheduleOverride: 'hourly', }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0].code).toBe('backup_stale'); expect(result[0].message).toContain('hourly'); }); test('manual schedule: never flags stale (user-driven cadence)', async () => { const result = await auditBackups({ modules: [ makeModule('lunacycle', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 365 * ONE_DAY, // 1 year old schedule: 'manual', }), ], now: () => NOW, }); expect(result).toEqual([]); }); // Silence-by-default is the bug: an undeclared cadence is how celilo-mgmt // went 55 days without a backup and nobody was told. Opting out takes an // explicit `manual` — see services/backup-schedule.ts. test('no schedule declared → daily, so a year-old backup is stale', async () => { const result = await auditBackups({ modules: [ makeModule('lunacycle', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 365 * ONE_DAY, }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ code: 'backup_stale', subject: 'lunacycle' }); expect(result[0].message).toContain('daily'); }); test('daily schedule: 27h-old is stale', async () => { const result = await auditBackups({ modules: [ makeModule('authentik', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 27 * HOUR, schedule: 'daily', }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0].code).toBe('backup_stale'); expect(result[0].message).toContain('daily'); }); test('daily schedule: 24h-old is fresh (within grace)', async () => { const result = await auditBackups({ modules: [ makeModule('authentik', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 24 * HOUR, schedule: 'daily', }), ], now: () => NOW, }); expect(result).toEqual([]); }); test('hourly schedule: 3h-old is stale', async () => { const result = await auditBackups({ modules: [ makeModule('chat', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 3 * HOUR, schedule: 'hourly', }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0].code).toBe('backup_stale'); }); test('weekly schedule: 5d-old is fresh', async () => { const result = await auditBackups({ modules: [ makeModule('archive', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 5 * ONE_DAY, schedule: 'weekly', }), ], now: () => NOW, }); expect(result).toEqual([]); }); test('weekly schedule: 10d-old is stale', async () => { const result = await auditBackups({ modules: [ makeModule('archive', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 10 * ONE_DAY, schedule: 'weekly', }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0].code).toBe('backup_stale'); }); test('monthly schedule: 30d-old is fresh', async () => { const result = await auditBackups({ modules: [ makeModule('cold-store', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 30 * ONE_DAY, schedule: 'monthly', }), ], now: () => NOW, }); expect(result).toEqual([]); }); test('monthly schedule: 35d-old is stale', async () => { const result = await auditBackups({ modules: [ makeModule('cold-store', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 35 * ONE_DAY, schedule: 'monthly', }), ], now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0].code).toBe('backup_stale'); }); test('staleAfterMs override forces threshold for every module', async () => { const result = await auditBackups({ modules: [ makeModule('lunacycle', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 2 * ONE_DAY, schedule: 'monthly', // would normally be fresh }), ], staleAfterMs: ONE_DAY, now: () => NOW, }); expect(result).toHaveLength(1); expect(result[0].code).toBe('backup_stale'); }); test('mixed report across multiple modules', async () => { const result = await auditBackups({ modules: [ makeModule('caddy', { hasBackupHook: false, lastSuccessfulBackupAt: null }), // skipped makeModule('lunacycle', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 3 * HOUR, schedule: 'daily', }), // fresh makeModule('authentik', { hasBackupHook: true, lastSuccessfulBackupAt: null, schedule: 'daily', }), // missing makeModule('homebridge', { hasBackupHook: true, lastSuccessfulBackupAt: NOW - 30 * ONE_DAY, schedule: 'weekly', }), // stale ], now: () => NOW, }); expect(result).toHaveLength(2); expect(result.map((f) => f.subject).sort()).toEqual(['authentik', 'homebridge']); }); // The regression: backups were complaining about IMPORTED modules // having no successful backup. There's no live state on an // IMPORTED module, so there's nothing to back up — skip entirely // rather than emit a misleading finding. test('skips non-deployed (IMPORTED) modules entirely', async () => { const importedModule = { ...makeModule('authentik', { hasBackupHook: true, lastSuccessfulBackupAt: null, schedule: 'daily', }), state: 'IMPORTED', }; const result = await auditBackups({ modules: [importedModule], now: () => NOW, }); expect(result).toEqual([]); }); });