Files
multi-simadmin/apps/api/src/infrastructure/database/database.test.ts
T

426 lines
16 KiB
TypeScript

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<string> {
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();
});
});