/** * Tests for `storage set-path`. * * The motivating case (#566) is celilo-mgr: `local-backups` points at * `/Users/pbanka/hobby/backups/celilo-backups/`, a macOS path that came * across when the database was restored onto a Linux host. The directory * does not exist there. Relocating must therefore succeed with nothing * migrated — not error, and not claim files were moved — and must not * carry the four-month-old `✓ Verified` stamp onto the new path. * * Isolation: CELILO_DB_PATH / CELILO_DATA_DIR are set before the SUT is * imported, so nothing touches the production database. */ import { afterAll, describe, expect, test } from 'bun:test'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync, } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; const testRoot = mkdtempSync(join(tmpdir(), 'celilo-setpath-')); process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db'); process.env.CELILO_DATA_DIR = join(testRoot, 'data'); const { addBackupStorage, getBackupStorageByStorageId, getStorageCredentials, updateStorageCredentials, } = await import('../../services/backup-storage'); const { handleStorageSetPath, inspectSourceDir, planRelocation, resolveTargetPath } = await import( './storage-set-path' ); afterAll(() => { rmSync(testRoot, { recursive: true, force: true }); }); describe('planRelocation (pure)', () => { const base = { sourceDir: '/old/celilo-backups', targetDir: '/new/celilo-backups' }; test('migrates when the source has files', () => { const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: true }); expect(plan.migrate).toBe(true); expect(plan.note).toContain('Migrating archives'); }); test('PRODUCTION CASE: source missing — skips migration, says so, does not error', () => { const plan = planRelocation({ ...base, sourceState: 'missing', migrateRequested: true }); expect(plan.migrate).toBe(false); expect(plan.note).toContain('does not exist'); expect(plan.note).toContain('nothing to migrate'); }); test('source unreadable — skips migration and says nothing was moved', () => { const plan = planRelocation({ ...base, sourceState: 'unreadable', migrateRequested: true }); expect(plan.migrate).toBe(false); expect(plan.note).toContain('not readable'); }); test('source empty — skips migration', () => { const plan = planRelocation({ ...base, sourceState: 'empty', migrateRequested: true }); expect(plan.migrate).toBe(false); expect(plan.note).toContain('empty'); }); test('--no-migrate wins even over a populated source', () => { const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: false }); expect(plan.migrate).toBe(false); expect(plan.note).toContain('--no-migrate'); }); }); describe('resolveTargetPath (pure)', () => { test('rejects a path identical to the current one', () => { const result = resolveTargetPath('/var/backups', '/var/backups/'); expect(result).toMatchObject({ success: false }); }); test('preserves a path containing a space', () => { const result = resolveTargetPath('/tmp/back ups/celilo', '/somewhere/else'); expect(result).toEqual({ path: '/tmp/back ups/celilo' }); }); test('expands a leading tilde', () => { const result = resolveTargetPath('~/backups', '/somewhere/else'); expect(result).toMatchObject({ path: join(homedir(), 'backups') }); }); }); describe('inspectSourceDir', () => { test('missing directory reports missing, not unreadable', () => { expect(inspectSourceDir(join(testRoot, 'no-such-dir'))).toBe('missing'); }); test('empty directory reports empty', () => { const dir = join(testRoot, 'empty-dir'); mkdirSync(dir, { recursive: true }); expect(inspectSourceDir(dir)).toBe('empty'); }); test('directory with files reports populated', () => { const dir = join(testRoot, 'full-dir'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'a.backup'), 'x'); expect(inspectSourceDir(dir)).toBe('populated'); }); test('unreadable directory reports unreadable, not missing', () => { const parent = join(testRoot, 'locked'); const dir = join(parent, 'celilo-backups'); mkdirSync(dir, { recursive: true }); chmodSync(parent, 0o000); try { expect(inspectSourceDir(dir)).toBe('unreadable'); } finally { chmodSync(parent, 0o700); } }); }); describe('updateStorageCredentials', () => { test('clears the verification stamp, so a stale ✓ never survives a credential change (#566)', async () => { const storage = await addBackupStorage({ name: 'Stamp Backups', providerName: 'local', credentials: { path: '/Users/nobody/old' }, }); const { getDb } = await import('../../db/client'); const { backupStorages } = await import('../../db/schema'); const { eq } = await import('drizzle-orm'); getDb() .update(backupStorages) .set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') }) .where(eq(backupStorages.id, storage.id)) .run(); await updateStorageCredentials(storage.id, { path: '/var/lib/celilo/backups' }); // Asserted WITHOUT a follow-up verify: even if celilo dies between // the path change and re-verification, the row must not still claim // the destination was verified. const after = getBackupStorageByStorageId(storage.storageId); expect(after?.verified).toBe(false); expect(after?.verifiedAt).toBeNull(); }); }); describe('handleStorageSetPath (end to end, isolated DB)', () => { test('PRODUCTION CASE: current path does not exist — path changes, nothing migrated, re-verified against the new path', async () => { const storage = await addBackupStorage({ name: 'Ghost Backups', providerName: 'local', credentials: { path: '/Users/nobody/hobby/backups' }, }); // Simulate the stale stamp: the row says Verified from a host that // no longer exists. const { getDb } = await import('../../db/client'); const { backupStorages } = await import('../../db/schema'); const { eq } = await import('drizzle-orm'); getDb() .update(backupStorages) .set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') }) .where(eq(backupStorages.id, storage.id)) .run(); const newPath = join(testRoot, 'relocated'); const result = await handleStorageSetPath([storage.storageId, newPath]); expect(result.success).toBe(true); const creds = await getStorageCredentials(storage.id); expect(creds).toMatchObject({ path: newPath }); // The stamp must describe the NEW path, not the dead one. const after = getBackupStorageByStorageId(storage.storageId); expect(after?.verified).toBe(true); expect(after?.verifiedAt?.getTime()).toBeGreaterThan( new Date('2026-04-08T02:24:43.000Z').getTime(), ); }); test('moves existing archives to the new location', async () => { const oldPath = join(testRoot, 'movable-old'); const newPath = join(testRoot, 'movable-new'); mkdirSync(join(oldPath, 'celilo-backups', '2026-08-01'), { recursive: true }); writeFileSync(join(oldPath, 'celilo-backups', '2026-08-01', 'x.backup'), 'payload'); const storage = await addBackupStorage({ name: 'Movable Backups', providerName: 'local', credentials: { path: oldPath }, }); const result = await handleStorageSetPath([storage.storageId, newPath]); expect(result.success).toBe(true); expect(existsSync(join(newPath, 'celilo-backups', '2026-08-01', 'x.backup'))).toBe(true); expect(existsSync(join(oldPath, 'celilo-backups'))).toBe(false); }); test('preserves archive mtimes — a move does not restamp backups', async () => { const oldPath = join(testRoot, 'mtime-old'); const newPath = join(testRoot, 'mtime-new'); const archive = join(oldPath, 'celilo-backups', 'a.backup'); mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true }); writeFileSync(archive, 'payload'); const stamp = new Date('2026-04-26T01:37:53.740Z'); utimesSync(archive, stamp, stamp); const storage = await addBackupStorage({ name: 'Mtime Backups', providerName: 'local', credentials: { path: oldPath }, }); await handleStorageSetPath([storage.storageId, newPath]); const moved = statSync(join(newPath, 'celilo-backups', 'a.backup')); expect(Math.round(moved.mtimeMs)).toBe(stamp.getTime()); }); test('--no-migrate leaves the old archives where they are', async () => { const oldPath = join(testRoot, 'kept-old'); const newPath = join(testRoot, 'kept-new'); mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true }); writeFileSync(join(oldPath, 'celilo-backups', 'y.backup'), 'payload'); const storage = await addBackupStorage({ name: 'Kept Backups', providerName: 'local', credentials: { path: oldPath }, }); const result = await handleStorageSetPath([storage.storageId, newPath], { 'no-migrate': true, }); expect(result.success).toBe(true); expect(existsSync(join(oldPath, 'celilo-backups', 'y.backup'))).toBe(true); expect(existsSync(join(newPath, 'celilo-backups', 'y.backup'))).toBe(false); }); test('handles a new path containing a space', async () => { const oldPath = join(testRoot, 'spacey-old'); const newPath = join(testRoot, 'back ups', "Bob's celilo"); mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true }); writeFileSync(join(oldPath, 'celilo-backups', 'z.backup'), 'payload'); const storage = await addBackupStorage({ name: 'Spacey Backups', providerName: 'local', credentials: { path: oldPath }, }); const result = await handleStorageSetPath([storage.storageId, newPath]); expect(result.success).toBe(true); expect(existsSync(join(newPath, 'celilo-backups', 'z.backup'))).toBe(true); }); test('rejects an unknown storage id', async () => { const result = await handleStorageSetPath(['no-such-storage', join(testRoot, 'x')]); expect(result).toMatchObject({ success: false }); }); test('requires both a storage id and a path', async () => { const result = await handleStorageSetPath(['only-one-arg']); expect(result).toMatchObject({ success: false }); }); });