426 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|