import { link, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { backupDatabase, restoreDatabase } from './backup.js'; import { openDatabase } from './database.js'; import { MIGRATIONS, migrateDatabase } from './migrations.js'; const directories: string[] = []; async function temporaryDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-database-')); directories.push(directory); return directory; } afterEach(async () => { await Promise.all( directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), ); }); describe('database migrations', () => { it('rejects duplicate, unordered, backfilled, and drifted migration catalogs', async () => { const directory = await temporaryDirectory(); const database = openDatabase(join(directory, 'app.sqlite')); const one = { id: 1, name: 'one', statements: ['CREATE TABLE one (id TEXT)'] }; migrateDatabase(database, [one]); expect(() => migrateDatabase(database, [{ ...one, statements: ['CREATE TABLE changed (id TEXT)'] }]), ).toThrow(/checksum|drift/i); expect(() => migrateDatabase(database, [one, { ...one, name: 'two' }])).toThrow( /duplicate.*id/i, ); expect(() => migrateDatabase(database, [one, { id: 2, name: 'one', statements: [] }])).toThrow( /duplicate.*name/i, ); expect(() => migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }, one])).toThrow( /increasing/i, ); migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }]); expect(() => migrateDatabase(database, [{ id: 0, name: 'backfill', statements: [] }])).toThrow( /backfill/i, ); database.close(); }); it('creates all business tables, migration metadata, indexes, foreign keys, and UTC timestamp fields', async () => { const directory = await temporaryDirectory(); const database = openDatabase(join(directory, 'app.sqlite')); migrateDatabase(database); const tables = database .prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", ) .all() .map((row) => (row as { name: string }).name); expect(tables).toEqual([ 'app_settings', 'audit_events', 'capabilities', 'console_auth_config', 'console_auth_sessions', 'event_journal', 'instance_tags', 'instances', 'job_attempts', 'job_items', 'jobs', 'operation_preparations', 'schema_migrations', 'secret_cleanup_tasks', 'secret_references', 'status_snapshots', ]); expect(database.pragma('foreign_keys', { simple: true })).toBe(1); const instanceColumns = database.pragma('table_info(instances)') as Array<{ name: string; notnull: number; }>; expect(instanceColumns).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'config_revision', notnull: 1 }), expect.objectContaining({ name: 'created_at', notnull: 1 }), expect.objectContaining({ name: 'updated_at', notnull: 1 }), ]), ); const foreignKeys = database.pragma('foreign_key_list(job_items)') as Array<{ from: string; on_delete: string; table: string; }>; expect(foreignKeys).toEqual( expect.arrayContaining([ expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }), ]), ); expect(foreignKeys).not.toEqual( expect.arrayContaining([ expect.objectContaining({ from: 'instance_id', table: 'instances' }), ]), ); const jobColumns = database.pragma('table_info(jobs)') as Array<{ name: string }>; expect(jobColumns).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'root_job_id' }), expect.objectContaining({ name: 'retry_of_job_id' }), ]), ); const indexes = database .prepare( "SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' ORDER BY name", ) .all() .map((row) => (row as { name: string }).name); expect(indexes).toEqual( expect.arrayContaining([ 'idx_audit_events_created_at', 'idx_console_auth_sessions_expires_at', 'idx_event_journal_sequence', 'idx_jobs_status_created_at', 'idx_status_snapshots_instance_observed_at', ]), ); const now = '2026-07-16T00:00:00.000Z'; database .prepare( 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', ) .run('cleanup-owner', 'Cleanup owner', 'http://10.0.0.9', 'password', 1, 1, now, now); database .prepare( 'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', ) .run( 'ref-one', 'cleanup-owner', 'instance-password', 'macos-keychain', 'opaque-one', now, now, ); expect(() => database .prepare( 'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', ) .run( 'ref-two', 'cleanup-owner', 'instance-password', 'macos-keychain', 'opaque-two', now, now, ), ).toThrow(/unique/i); database .prepare( 'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)', ) .run('opaque-one', 'cleanup-owner', 'instance-password', 'macos-keychain', now, now); database.prepare('DELETE FROM instances WHERE id=?').run('cleanup-owner'); expect(database.prepare('SELECT reference FROM secret_cleanup_tasks').all()).toEqual([ { reference: 'opaque-one' }, ]); database.close(); }); it('upgrades duplicate v1 secret references by retaining one canonical row and queuing the rest', async () => { const directory = await temporaryDirectory(); const database = openDatabase(join(directory, 'app.sqlite')); migrateDatabase(database, [MIGRATIONS[0]!]); const older = '2026-07-15T00:00:00.000Z'; const newer = '2026-07-16T00:00:00.000Z'; database .prepare( 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', ) .run('owner', 'Owner', 'http://10.0.0.8', 'password', 1, 1, older, newer); const reference = (slot: string) => `keychain://multi-simadmin/${Buffer.from(JSON.stringify(['owner', 'instance-password', slot]), 'utf8').toString('base64url')}`; const insert = database.prepare( 'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', ); insert.run( 'old-ref', 'owner', 'instance-password', 'macos-keychain', reference('old-slot'), older, older, ); insert.run( 'new-ref', 'owner', 'instance-password', 'macos-keychain', reference('new-slot'), newer, newer, ); migrateDatabase(database); expect(database.prepare('SELECT id FROM secret_references').all()).toEqual([{ id: 'new-ref' }]); expect( database .prepare('SELECT reference,instance_id,purpose,provider FROM secret_cleanup_tasks') .all(), ).toEqual([ { reference: reference('old-slot'), instance_id: 'owner', purpose: 'instance-password', provider: 'macos-keychain', }, ]); expect(() => migrateDatabase(database)).not.toThrow(); database.close(); }); it('replays without resetting persisted data', async () => { const directory = await temporaryDirectory(); const database = openDatabase(join(directory, 'app.sqlite')); migrateDatabase(database); database .prepare( 'INSERT INTO instances (id, name, base_url, config_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', ) .run( 'one', 'One', 'http://example.invalid', 7, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', ); migrateDatabase(database); expect( database.prepare('SELECT name, config_revision FROM instances WHERE id = ?').get('one'), ).toEqual({ config_revision: 7, name: 'One', }); expect(database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get()).toEqual({ count: MIGRATIONS.length, }); database.close(); }); it('rolls back the entire migration when a statement fails', async () => { const directory = await temporaryDirectory(); const database = openDatabase(join(directory, 'app.sqlite')); expect(() => migrateDatabase(database, [ { id: 999, name: 'deliberately-broken', statements: ['CREATE TABLE should_rollback (id TEXT PRIMARY KEY)', 'THIS IS NOT SQL'], }, ]), ).toThrow(); expect( database.prepare("SELECT name FROM sqlite_master WHERE name = 'should_rollback'").get(), ).toBeUndefined(); expect( database.prepare("SELECT name FROM sqlite_master WHERE name = 'schema_migrations'").get(), ).toBeUndefined(); database.close(); }); }); describe('database backup and restore', () => { it('honours a canonical restore marker when opening through a symlinked parent alias', async () => { const directory = await temporaryDirectory(); const realDirectory = join(directory, 'real'); const aliasDirectory = join(directory, 'alias'); await mkdir(realDirectory); const databasePath = join(realDirectory, 'app.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); database.close(); await writeFile( `${databasePath}.restore-in-progress`, JSON.stringify({ version: 1, quarantinedSidecars: [] }), ); await symlink(realDirectory, aliasDirectory); expect(() => openDatabase(join(aliasDirectory, 'app.sqlite'))).toThrow(/restore|recovery/i); }); it('refuses restore while a managed live connection is open', async () => { const directory = await temporaryDirectory(); const databasePath = join(directory, 'app.sqlite'); const backupPath = join(directory, 'backup.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); await backupDatabase(database, backupPath); await expect(restoreDatabase(databasePath, backupPath)).rejects.toThrow(/open|connection/i); database.close(); }); it('refuses restore through hardlink and symlinked-parent aliases while a managed connection is open', async () => { const directory = await temporaryDirectory(); const realDirectory = join(directory, 'real'); const aliasDirectory = join(directory, 'alias'); await mkdir(realDirectory); await symlink(realDirectory, aliasDirectory); const databasePath = join(realDirectory, 'app.sqlite'); const hardlinkPath = join(directory, 'hardlink.sqlite'); const backupPath = join(directory, 'backup.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); await backupDatabase(database, backupPath); await link(databasePath, hardlinkPath); await expect(restoreDatabase(hardlinkPath, backupPath)).rejects.toThrow( /open|connection|identity/i, ); await expect(restoreDatabase(join(aliasDirectory, 'app.sqlite'), backupPath)).rejects.toThrow( /open|connection|identity/i, ); database.close(); }); it('rejects backup destinations that alias the managed live database', async () => { const directory = await temporaryDirectory(); const databasePath = join(directory, 'app.sqlite'); const hardlinkPath = join(directory, 'hardlink.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); await link(databasePath, hardlinkPath); await expect(backupDatabase(database, hardlinkPath)).rejects.toThrow(/same|alias|identity/i); database.close(); }); it('creates backups with owner-only permissions', async () => { const directory = await temporaryDirectory(); const database = openDatabase(join(directory, 'app.sqlite')); migrateDatabase(database); const backupPath = join(directory, 'backup.sqlite'); await backupDatabase(database, backupPath); expect((await lstat(backupPath)).mode & 0o777).toBe(0o600); database.close(); }); it('rejects same-path, symlink, and hardlink restore aliases', async () => { const directory = await temporaryDirectory(); const databasePath = join(directory, 'app.sqlite'); const backupPath = join(directory, 'backup.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); await backupDatabase(database, backupPath); database.close(); await expect(restoreDatabase(databasePath, databasePath)).rejects.toThrow(/same|alias/i); const symlinkPath = join(directory, 'backup-link.sqlite'); await symlink(backupPath, symlinkPath); await expect(restoreDatabase(databasePath, symlinkPath)).rejects.toThrow(/symbolic|symlink/i); const hardlinkPath = join(directory, 'live-hardlink.sqlite'); await link(databasePath, hardlinkPath); await expect(restoreDatabase(databasePath, hardlinkPath)).rejects.toThrow(/same|alias/i); }); it('backs up an active WAL database, then atomically restores its earlier state', async () => { const directory = await temporaryDirectory(); const databasePath = join(directory, 'app.sqlite'); const backupPath = join(directory, 'backup.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); database .prepare( 'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)', ) .run('colour', '"blue"', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); await backupDatabase(database, backupPath); database.prepare('UPDATE app_settings SET value_json = ? WHERE key = ?').run('"red"', 'colour'); database.close(); await restoreDatabase(databasePath, backupPath); const restored = openDatabase(databasePath); expect( restored.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('colour'), ).toEqual({ value_json: '"blue"' }); expect(restored.pragma('integrity_check', { simple: true })).toBe('ok'); restored.close(); }); it('rejects a corrupt backup without changing the live database', async () => { const directory = await temporaryDirectory(); const databasePath = join(directory, 'app.sqlite'); const corruptPath = join(directory, 'corrupt.sqlite'); const database = openDatabase(databasePath); migrateDatabase(database); database .prepare( 'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)', ) .run('safe', 'true', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); database.close(); const before = await readFile(databasePath); await writeFile(corruptPath, 'not a sqlite database'); await expect(restoreDatabase(databasePath, corruptPath)).rejects.toThrow( /backup|integrity|database/i, ); expect(await readFile(databasePath)).toEqual(before); const unchanged = openDatabase(databasePath); expect( unchanged.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('safe'), ).toEqual({ value_json: 'true' }); unchanged.close(); }); });