feat(migration): add safe legacy import preview
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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<string> {
|
||||
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<unknown>): Promise<Error> {
|
||||
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'],
|
||||
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('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('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 VALUES (?,?,?,?,?,?,?,?)').run(
|
||||
'alpha',
|
||||
'Different',
|
||||
'https://203.0.113.99',
|
||||
'none',
|
||||
1,
|
||||
7,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
db.prepare('INSERT INTO instances 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user