Files
multi-simadmin/apps/api/src/application/legacy-import/legacy-import.test.ts
T
chick f9186bd851 feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
2026-09-05 18:53:04 +08:00

302 lines
12 KiB
TypeScript

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'],
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 });
});
});