import Database from 'better-sqlite3'; import { lstat, mkdtemp, readFile, rename, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import { confirmLegacyImport, previewLegacyImport, type LegacyImportPreview, } from './legacy-import.js'; const databases: Database.Database[] = []; afterEach(() => { for (const database of databases.splice(0)) database.close(); }); function database(): Database.Database { const result = new Database(':memory:'); result.pragma('foreign_keys = ON'); migrateDatabase(result); databases.push(result); return result; } async function legacyFile(value: unknown): Promise { const directory = await mkdtemp(join(tmpdir(), 'legacy-import-')); const path = join(directory, 'legacy.json'); await writeFile(path, JSON.stringify(value), { mode: 0o600 }); return path; } const password = 'phase-two-super-secret-value'; const valid = { server: { host: '0.0.0.0', port: 8788 }, instances: [ { id: 'alpha', name: 'Alpha', url: 'https://203.0.113.8/path?discard=yes#fragment', description: 'primary', tags: ['prod', 'blue'], auth: { mode: 'password', password }, capabilities: ['status.read', 'sim.restart'], }, { id: 'beta', url: 'http://[2001:4860:4860::8888]:8080', auth: { mode: 'none' } }, ], }; function scan(value: unknown): string { if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message }); return JSON.stringify(value); } function scanDatabaseText(database: Database.Database): string { const tables = database .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") .all() as { name: string }[]; const values: unknown[] = []; for (const { name } of tables) { const quotedTable = `"${name.replaceAll('"', '""')}"`; const columns = database.prepare(`PRAGMA table_info(${quotedTable})`).all() as { name: string; type: string; }[]; const textColumns = columns.filter(({ type }) => type.toUpperCase().includes('TEXT')); if (textColumns.length === 0) continue; const selection = textColumns .map(({ name: column }) => `"${column.replaceAll('"', '""')}"`) .join(','); values.push(...database.prepare(`SELECT ${selection} FROM ${quotedTable}`).all()); } return JSON.stringify(values); } async function rejection(action: () => Promise): Promise { try { await action(); } catch (error) { return error as Error; } throw new Error('expected rejection'); } describe('legacy import', () => { it('previews a deeply frozen, redacted, digest-bound plan without writing the database', async () => { const path = await legacyFile(valid); const db = database(); const before = db.serialize(); const preview = await previewLegacyImport(path, db); expect(preview.counts).toEqual({ total: 2, planned: 2, conflict: 0, unchanged: 0, secretPending: 1, }); expect(preview.instances[0]).toEqual({ id: 'alpha', name: 'Alpha', baseUrl: 'https://203.0.113.8/path', description: 'primary', tags: ['prod', 'blue'], capabilities: ['status.read', 'sim.restart'], authMode: 'password', credentialPresent: true, status: 'planned', }); expect(preview.source.digest).toMatch(/^[a-f0-9]{64}$/); expect(preview.planDigest).toMatch(/^[a-f0-9]{64}$/); expect(preview.source.identity).toMatchObject({ device: expect.any(String), inode: expect.any(String), }); expect(Object.isFrozen(preview)).toBe(true); expect(Object.isFrozen(preview.instances[0]?.tags)).toBe(true); expect(scan(preview)).not.toContain(password); expect(db.serialize()).toEqual(before); }); it.each([ ['bad JSON', '{'], ['array root', '[]'], ['missing instances', '{}'], ['instances object', '{"instances":{}}'], [ 'dangerous key', '{"instances":[{"id":"x","url":"https://203.0.113.1","__proto__":{"polluted":true}}]}', ], [ 'duplicate id', '{"instances":[{"id":"x","url":"https://203.0.113.1"},{"id":"x","url":"https://203.0.113.2"}]}', ], ['credentials in URL', '{"instances":[{"id":"x","url":"https://u:p@203.0.113.1"}]}'], ['hostname', '{"instances":[{"id":"x","url":"https://example.com"}]}'], ['loopback', '{"instances":[{"id":"x","url":"http://127.0.0.1"}]}'], ['malformed member', '{"instances":[{"id":"ok","url":"https://203.0.113.1"},null]}'], ])('rejects %s as an all-or-nothing malformed preview', async (_name, contents) => { const path = await legacyFile({}); await writeFile(path, contents); const error = await rejection(() => previewLegacyImport(path)); expect(scan(error)).not.toContain(password); }); it('never includes a malformed source excerpt in an error', async () => { const path = await legacyFile({}); const marker = 'malformed-secret-never-echo'; await writeFile(path, `{"instances":[],"password":"${marker}" trailing`); const error = await rejection(() => previewLegacyImport(path)); expect(error.message).toBe('Legacy config is not valid JSON'); expect(scan(error)).not.toContain(marker); }); it('rejects symlinks and non-regular files', async () => { const path = await legacyFile(valid); const link = `${path}.link`; await symlink(path, link); await expect(previewLegacyImport(link)).rejects.toThrow(/regular|symlink/i); const details = await lstat(link); expect(details.isSymbolicLink()).toBe(true); await expect(previewLegacyImport(join(path, 'child'))).rejects.toThrow(); }); it('rejects oversized source files before parsing without echoing their content', async () => { const path = await legacyFile({}); const marker = 'oversized-secret-marker'; await writeFile(path, JSON.stringify({ instances: [], padding: marker.repeat(100_000) })); const error = await rejection(() => previewLegacyImport(path)); expect(error.message).toMatch(/large|size|limit/i); expect(scan(error)).not.toContain(marker); }); it('imports atomically, ignores legacy server, records pending secrets without storing credentials, and is idempotent', async () => { const path = await legacyFile(valid); const db = database(); const preview = await previewLegacyImport(path, db); const result = await confirmLegacyImport(path, preview, db); expect(result).toMatchObject({ imported: 2, unchanged: 0, secretPending: ['alpha'] }); expect(scan(result)).not.toContain(password); expect( db .prepare( 'SELECT id,name,base_url,auth_mode,enabled,config_revision FROM instances ORDER BY id', ) .all(), ).toEqual([ { id: 'alpha', name: 'Alpha', base_url: 'https://203.0.113.8/path', auth_mode: 'none', enabled: 1, config_revision: 1, }, { id: 'beta', name: 'beta', base_url: 'http://[2001:4860:4860::8888]:8080', auth_mode: 'none', enabled: 1, config_revision: 1, }, ]); expect( db.prepare('SELECT instance_id,tag FROM instance_tags ORDER BY instance_id,tag').all(), ).toEqual([ { instance_id: 'alpha', tag: 'blue' }, { instance_id: 'alpha', tag: 'prod' }, ]); expect(db.prepare('SELECT COUNT(*) AS count FROM secret_references').get()).toEqual({ count: 0, }); expect( db .prepare("SELECT COUNT(*) AS count FROM app_settings WHERE key LIKE 'legacy-import.%'") .get(), ).toEqual({ count: 2 }); expect(scanDatabaseText(db)).not.toContain(password); const secondPreview = await previewLegacyImport(path, db); expect(secondPreview.counts).toMatchObject({ planned: 0, unchanged: 2, conflict: 0 }); const second = await confirmLegacyImport(path, secondPreview, db); expect(second).toMatchObject({ imported: 0, unchanged: 2, secretPending: ['alpha'] }); expect(db.prepare('SELECT id,config_revision FROM instances ORDER BY id').all()).toEqual([ { id: 'alpha', config_revision: 1 }, { id: 'beta', config_revision: 1 }, ]); }); it('binds confirmation to source identity, source digest, and plan digest', async () => { const path = await legacyFile(valid); const db = database(); const preview = await previewLegacyImport(path, db); await writeFile(path, JSON.stringify({ instances: [] })); await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow( /changed|digest|identity/i, ); expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 }); const fresh = await legacyFile(valid); const good = await previewLegacyImport(fresh, db); const replacement = `${fresh}.new`; await writeFile(replacement, await readFile(fresh)); await rename(replacement, fresh); const replacementPreview = await previewLegacyImport(fresh, db); await expect(confirmLegacyImport(fresh, good, db)).rejects.toThrow(/changed|identity/i); const tampered = { ...replacementPreview, planDigest: '0'.repeat(64) } as LegacyImportPreview; await expect(confirmLegacyImport(fresh, tampered, db)).rejects.toThrow(/digest/i); }); it('binds status and auth mode into the execution plan digest', async () => { const path = await legacyFile(valid); const db = database(); const preview = await previewLegacyImport(path, db); const instance = preview.instances[0]!; const statusTampered = { ...preview, instances: [{ ...instance, status: 'unchanged' as const }, ...preview.instances.slice(1)], } as LegacyImportPreview; await expect(confirmLegacyImport(path, statusTampered, db)).rejects.toThrow(/digest/i); const authTampered = { ...preview, instances: [{ ...instance, authMode: 'none' as const }, ...preview.instances.slice(1)], } as LegacyImportPreview; await expect(confirmLegacyImport(path, authTampered, db)).rejects.toThrow(/digest/i); }); it('reports conflicts and confirmation rejects the entire transaction', async () => { const path = await legacyFile(valid); const db = database(); const now = new Date().toISOString(); db.prepare( 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', ).run('alpha', 'Different', 'https://203.0.113.99', 'none', 1, 7, now, now); db.prepare( 'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', ).run('owner', 'Owner', 'http://[2001:4860:4860::8888]:8080', 'none', 1, 1, now, now); const preview = await previewLegacyImport(path, db); expect(preview.instances.map((item) => item.status)).toEqual(['conflict', 'conflict']); expect(preview.counts.conflict).toBe(2); await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/conflict/i); expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 2 }); }); it('rolls back all writes on any database failure', async () => { const path = await legacyFile(valid); const db = database(); db.exec( "CREATE TRIGGER fail_beta BEFORE INSERT ON instances WHEN NEW.id = 'beta' BEGIN SELECT RAISE(ABORT, 'forced'); END", ); const preview = await previewLegacyImport(path, db); await expect(confirmLegacyImport(path, preview, db)).rejects.toThrow(/forced/); expect(db.prepare('SELECT COUNT(*) AS count FROM instances').get()).toEqual({ count: 0 }); expect(db.prepare('SELECT COUNT(*) AS count FROM app_settings').get()).toEqual({ count: 0 }); }); });