/** * Tests for the `system update` storage pre-flight. * * Background: an operator on celilo-mgmt hit a confusing chain when * they ran `celilo storage add local /var/backups/celilo` (a system * path that needs root). The verify step failed with EACCES, leaving * an unverified storage row on disk. The next `celilo system update` * said "No default backup storage configured" — technically true * (unverified storage never auto-defaults), but misleading: there * IS a storage, it just needs fixing. The pre-flight now distinguishes * the cases and points at the right next command for each. */ import { describe, expect, test } from 'bun:test'; import type { ModuleSnapshot } from '../../services/update/orchestrator'; import { type BackupStorageLike, checkBackupStoragePreflight, shouldTakeBackup, } from './system-update'; const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true }); const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false }); describe('checkBackupStoragePreflight', () => { test('verified default → ok', () => { const result = checkBackupStoragePreflight({ defaultStorage: verified('home-backups'), allStorages: [verified('home-backups')], }); expect(result.kind).toBe('ok'); }); test('default exists but unverified → suggests `storage verify`', () => { const result = checkBackupStoragePreflight({ defaultStorage: unverified('home-backups'), allStorages: [unverified('home-backups')], }); if (result.kind !== 'error') throw new Error('expected error'); expect(result.message).toContain("Default backup storage 'home-backups' is not verified"); expect(result.message).toContain('celilo storage verify home-backups'); }); test('no storage at all → suggests `storage add local`', () => { const result = checkBackupStoragePreflight({ defaultStorage: null, allStorages: [], }); if (result.kind !== 'error') throw new Error('expected error'); expect(result.message).toContain('No backup storage configured'); expect(result.message).toContain('celilo storage add local'); // Always offer the --no-backup escape so the operator knows the // CLI self-update can still run on a system without storage. expect(result.message).toContain('--no-backup'); }); // The exact case the operator hit on celilo-mgmt: ran storage add, // path was unwriteable (/var/backups/celilo without sudo), verify // failed, the unverified row stayed in the DB and isn't default. test('only unverified storage → suggests `storage verify`, not `storage add`', () => { const result = checkBackupStoragePreflight({ defaultStorage: null, allStorages: [unverified('local-backups')], }); if (result.kind !== 'error') throw new Error('expected error'); expect(result.message).toContain('none is verified yet'); expect(result.message).toContain('Storages: local-backups'); expect(result.message).toContain('celilo storage verify '); // Critically, this case does NOT direct the operator to run // `storage add local` — they already did, the row exists. expect(result.message).not.toContain('celilo storage add local'); // Surface the most common cause inline rather than burying it. expect(result.message).toContain('elevated permissions'); expect(result.message).toContain('--no-backup'); }); test('multiple unverified storages → lists all storage IDs', () => { const result = checkBackupStoragePreflight({ defaultStorage: null, allStorages: [unverified('local-a'), unverified('local-b'), unverified('local-c')], }); if (result.kind !== 'error') throw new Error('expected error'); expect(result.message).toContain('Storages: local-a, local-b, local-c'); }); test('verified storage exists but none is default → suggests `storage set-default`', () => { const result = checkBackupStoragePreflight({ defaultStorage: null, allStorages: [verified('home-backups'), unverified('s3-cold')], }); if (result.kind !== 'error') throw new Error('expected error'); expect(result.message).toContain('verified but no default is set'); expect(result.message).toContain('Verified: home-backups'); // Doesn't mention the unverified one in the "Verified:" listing // (that would mislead). expect(result.message).not.toContain('Verified: home-backups, s3-cold'); expect(result.message).toContain('celilo storage set-default '); }); test('multiple verified, no default → lists all verified IDs', () => { const result = checkBackupStoragePreflight({ defaultStorage: null, allStorages: [verified('home-backups'), verified('s3-cold'), unverified('nas')], }); if (result.kind !== 'error') throw new Error('expected error'); expect(result.message).toContain('Verified: home-backups, s3-cold'); }); }); const snap = ( id: string, installedVersion: string, latestVersion: string | null, ): ModuleSnapshot => ({ id, installedVersion, latestVersion, installedProvides: {}, pendingRequires: {}, }); const snapshotsOf = (...entries: ModuleSnapshot[]): Map => new Map(entries.map((s) => [s.id, s])); describe('shouldTakeBackup', () => { test('false when no module updates at all', () => { expect( shouldTakeBackup({ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+5')), wasDeployed: new Set(['caddy']), }), ).toBe(false); }); test('true when a deployed module has a registry-newer version', () => { expect( shouldTakeBackup({ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+6')), wasDeployed: new Set(['caddy']), }), ).toBe(true); }); // The exact case the operator hit on celilo-mgmt: namecheap was // imported but never deployed; system update wanted to back up the // celilo DB even though no live state would be touched. test('false when only IMPORTED modules have updates (live state untouched)', () => { expect( shouldTakeBackup({ snapshots: snapshotsOf(snap('namecheap', '3.1.0+10', '3.1.1+4')), wasDeployed: new Set(), // nothing deployed }), ).toBe(false); }); test('true when any deployed module needs updating, regardless of imported ones', () => { expect( shouldTakeBackup({ snapshots: snapshotsOf( snap('namecheap', '3.1.0+10', '3.1.1+4'), // imported, has update snap('caddy', '2.0.0+5', '2.0.0+6'), // deployed, has update ), wasDeployed: new Set(['caddy']), }), ).toBe(true); }); test('false when registry has no info (latestVersion null)', () => { // Network failure — we don't know if updates exist. Default to // "no backup needed" rather than blocking the run; the per-module // upgrade step will report individual failures if any. expect( shouldTakeBackup({ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', null)), wasDeployed: new Set(['caddy']), }), ).toBe(false); }); test('false on empty snapshots', () => { expect( shouldTakeBackup({ snapshots: new Map(), wasDeployed: new Set(), }), ).toBe(false); }); });