feat(migration): add safe legacy import preview

This commit is contained in:
chick
2026-07-16 13:51:11 +08:00
parent ef9a634a02
commit 8a307781c6
4 changed files with 693 additions and 1 deletions
@@ -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 });
});
});