173 lines
11 KiB
JavaScript
173 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { discoverStorageConfig } from '../lib/auto-storage.mjs';
|
|
|
|
const database = 'local_app';
|
|
const server = 'test-server-uuid';
|
|
const input = () => ({ mysql: { database, user: 'local-test', password: 'db-test-only' }, projectProfile: 'local', _projectDownloadBaseUrl: 'http://localhost:48080/admin-api/infra/file' });
|
|
const source = value => ({ id: value, storage: 1, master: Buffer.from([0]), config: JSON.stringify({ domain: 'https://old.example' }) });
|
|
const target = (value = '30', master = 1) => ({ id: value, storage: 20, master: Buffer.from([master]), config: JSON.stringify({ endpoint: 'oss-cn-hangzhou.aliyuncs.com', bucket: 'migration-test', accessKey: 'oss-test-access', accessSecret: 'oss-secret-must-not-escape', domain: 'https://media.example' }) });
|
|
function binding(overrides = {}) {
|
|
return { server, schema: database, sourceConfigIds: ['5'], targetConfigId: '30', sourceMode: 'database', downloadBaseUrl: 'https://old.example/admin-api/infra/file', oldDomains: ['https://historic.example'], objectPrefix: 'migration/original', imagesOnly: true, referenceSchemas: [database, 'chat'], excludeReferenceTables: ['audit'], targetUrlMode: 'direct', publicDomain: 'https://original-media.example', ...overrides };
|
|
}
|
|
const run = (runId = 'original-run', status = 'partial', overrides = {}) => ({ run_id: runId, status, settings: JSON.stringify({ binding: binding(overrides), maxId: '9007199254740993' }) });
|
|
|
|
class FakeDb {
|
|
constructor(options = {}) {
|
|
this.server = server;
|
|
this.configs = [source('5'), target()];
|
|
this.sourceIds = ['5'];
|
|
this.runs = [];
|
|
this.domains = ['https://old.example'];
|
|
this.maximum = '9007199254740993';
|
|
this.calls = [];
|
|
Object.assign(this, options);
|
|
}
|
|
async execute(sql, params = []) {
|
|
assert.match(sql.trim(), /^SELECT\b/i, 'discovery must only issue SELECT');
|
|
this.calls.push({ sql, params });
|
|
if (sql.includes('@@server_uuid')) return [[{ server_uuid: this.server }]];
|
|
if (sql.includes('information_schema.TABLES')) {
|
|
assert.deepEqual(params, [database, 'infra_file_oss_migration_runs']);
|
|
return [this.runs.length ? [{ present: 1 }] : []];
|
|
}
|
|
if (sql.includes('infra_file_oss_migration_runs')) return [this.runs];
|
|
if (sql.includes('infra_file_config')) return [this.configs];
|
|
if (sql.includes('MAX(id)')) return [[{ max_id: this.maximum }]];
|
|
if (sql.includes('infra_file_content')) {
|
|
assert.match(sql, /deleted=0/);
|
|
return [this.sourceIds.filter(value => params.includes(value)).map(config_id => ({ config_id }))];
|
|
}
|
|
if (sql.includes('SUBSTRING_INDEX')) return [this.domains.map(domain => ({ domain }))];
|
|
throw new Error(`Unexpected test SQL: ${sql}`);
|
|
}
|
|
}
|
|
|
|
test('discovers source BLOB configurations and the unique OSS default using SELECT only', async () => {
|
|
const db = new FakeDb({ configs: [source('9007199254740995'), target(), source('12'), source('2'), source('999')], sourceIds: ['12', '9007199254740995', '2', '2'] });
|
|
const result = await discoverStorageConfig(db, input());
|
|
assert.deepEqual(result.sourceConfigIds, ['2', '12', '9007199254740995']);
|
|
assert.equal(result.targetConfigId, '30');
|
|
assert.equal(result.sourceMode, 'database');
|
|
assert.equal(result.downloadBaseUrl, input()._projectDownloadBaseUrl);
|
|
assert.equal(result.projectProfile, 'local');
|
|
assert.equal(result._autoDiscovery, true);
|
|
assert.deepEqual(result._databaseIdentity, { server, schema: database });
|
|
assert.match(result.runId, /^db-images-[a-f0-9]{16}$/);
|
|
assert.equal(JSON.stringify(result).includes('oss-secret-must-not-escape'), false);
|
|
assert.ok(db.calls.some(call => call.sql.includes('SELECT DISTINCT config_id FROM')));
|
|
});
|
|
|
|
test('generated runId is independent of source ordering, credentials, domains and remaining file metadata', async () => {
|
|
const first = await discoverStorageConfig(new FakeDb({ configs: [source('7'), target(), source('5')], sourceIds: ['7', '5'] }), input());
|
|
const secondInput = input();
|
|
secondInput.mysql.password = 'rotated-password';
|
|
const second = await discoverStorageConfig(new FakeDb({ configs: [source('5'), source('7'), target()], sourceIds: ['5', '7'], domains: [] }), secondInput);
|
|
assert.equal(second.runId, first.runId);
|
|
assert.deepEqual(second.sourceConfigIds, first.sourceConfigIds);
|
|
});
|
|
|
|
test('only valid HTTP base domains without credentials are inferred and sorted', async () => {
|
|
const result = await discoverStorageConfig(new FakeDb({ domains: ['https://z.example/', 'https://a.example/prefix', 'https://z.example', 'https://user:secret@example.com', 'ftp://file.example', 'https://x.example?secret=1', 'not a URL'] }), input());
|
|
assert.deepEqual(result.oldDomains, ['https://a.example/prefix', 'https://z.example']);
|
|
});
|
|
|
|
test('requires a unique default and refuses non-Aliyun defaults instead of selecting a different OSS', async () => {
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), target('30', 0)] }), input()), /默认.*唯一/);
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), target(), target('31')] }), input()), /默认.*唯一/);
|
|
const invalidDefault = target();
|
|
invalidDefault.config = JSON.stringify({ endpoint: 'minio.example', accessSecret: 'dont-leak' });
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), invalidDefault, target('31', 0)] }), input()), error => /阿里云/.test(error.message) && !error.message.includes('dont-leak'));
|
|
const malformedDefault = target();
|
|
malformedDefault.config = '{"secret":"dont-leak", broken';
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ configs: [source('5'), malformedDefault] }), input()), error => !error.message.includes('dont-leak'));
|
|
});
|
|
|
|
test('restores an unfinished run before current metadata can shrink its original scope', async () => {
|
|
const original = run('original-run', 'partial', { sourceConfigIds: ['7', '5'] });
|
|
const db = new FakeDb({ runs: [original], configs: [source('5'), source('7'), target()], sourceIds: [], domains: [] });
|
|
const result = await discoverStorageConfig(db, input(), { command: 'apply' });
|
|
assert.equal(result.runId, 'original-run');
|
|
assert.deepEqual(result.sourceConfigIds, ['7', '5'], 'historical order is part of the existing fingerprint');
|
|
assert.deepEqual(result.oldDomains, ['https://historic.example']);
|
|
assert.equal(result.objectPrefix, 'migration/original');
|
|
assert.equal(result.downloadBaseUrl, 'https://old.example/admin-api/infra/file');
|
|
assert.equal(result.targetPublicDomain, 'https://original-media.example');
|
|
assert.deepEqual(result.referenceSchemas, [database, 'chat']);
|
|
assert.ok(!db.calls.some(call => call.sql.includes('SELECT DISTINCT config_id')));
|
|
assert.ok(!db.calls.some(call => call.sql.includes('SUBSTRING_INDEX')));
|
|
});
|
|
|
|
test('explicit input fields override restored fields for downstream fingerprint validation', async () => {
|
|
const result = await discoverStorageConfig(new FakeDb({ runs: [run()] }), {
|
|
...input(), oldDomains: [], objectPrefix: 'migration/explicit', imagesOnly: false,
|
|
targetPublicDomain: 'https://explicit.example', sourceMode: 'http', downloadBaseUrl: 'https://explicit.example/admin-api/infra/file'
|
|
});
|
|
assert.deepEqual(result.oldDomains, []);
|
|
assert.equal(result.objectPrefix, 'migration/explicit');
|
|
assert.equal(result.imagesOnly, false);
|
|
assert.equal(result.targetPublicDomain, 'https://explicit.example');
|
|
assert.equal(result.sourceMode, 'http');
|
|
});
|
|
|
|
test('automatic resumed inputs and historical runs reject a changed default target', async () => {
|
|
const options = { configs: [source('5'), target('30', 0), target('31')] };
|
|
await assert.rejects(discoverStorageConfig(new FakeDb(options), { ...input(), targetConfigId: '30', _autoDiscovery: true }), /原迁移目标不同/);
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ ...options, runs: [run()] }), input()), /原迁移目标不同/);
|
|
});
|
|
|
|
test('rejects cross-server/schema inputs and restored history before accessing storage credentials', async () => {
|
|
for (const wrong of [{ server: 'other', schema: database }, { server, schema: 'other' }]) {
|
|
const db = new FakeDb();
|
|
await assert.rejects(discoverStorageConfig(db, { ...input(), _databaseIdentity: wrong }), /身份.*不一致/);
|
|
assert.equal(db.calls.length, 1);
|
|
}
|
|
const db = new FakeDb({ runs: [run('foreign', 'partial', { server: 'other' })] });
|
|
await assert.rejects(discoverStorageConfig(db, input()), /身份.*不一致/);
|
|
assert.ok(!db.calls.some(call => call.sql.includes('infra_file_config')));
|
|
});
|
|
|
|
test('historical selection refuses ambiguity but honors an explicit existing or new runId', async () => {
|
|
const unfinished = [run('one'), run('two')];
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ runs: unfinished }), input()), /多个未完成.*--run-id/);
|
|
const chosen = await discoverStorageConfig(new FakeDb({ runs: unfinished }), { ...input(), runId: 'two' });
|
|
assert.equal(chosen.runId, 'two');
|
|
const fresh = await discoverStorageConfig(new FakeDb({ runs: unfinished }), { ...input(), runId: 'new-run' });
|
|
assert.equal(fresh.runId, 'new-run');
|
|
assert.equal(fresh.objectPrefix, 'migration/db-images');
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ runs: [run('one', 'complete'), run('two', 'rolled_back')] }), input()), /多个历史.*--run-id/);
|
|
const single = await discoverStorageConfig(new FakeDb({ runs: [run('complete-run', 'complete')] }), input());
|
|
assert.equal(single.runId, 'complete-run');
|
|
const active = await discoverStorageConfig(new FakeDb({ runs: [run('old', 'complete'), run('active', 'needs-review')] }), input());
|
|
assert.equal(active.runId, 'active');
|
|
});
|
|
|
|
test('apply cannot resume a rollback while rollback does not require current storage configs or secrets', async () => {
|
|
for (const status of ['rollback', 'rollback_partial', 'rolled_back']) {
|
|
await assert.rejects(discoverStorageConfig(new FakeDb({ runs: [run('old', status)] }), input(), { command: 'apply' }), /回滚.*--run-id/);
|
|
}
|
|
const db = new FakeDb({ runs: [run('old', 'rollback_partial')], configs: [] });
|
|
const result = await discoverStorageConfig(db, input(), { command: 'rollback' });
|
|
assert.equal(result.runId, 'old');
|
|
assert.equal(result.targetConfigId, '30');
|
|
assert.ok(!db.calls.some(call => call.sql.includes('infra_file_config')));
|
|
await assert.rejects(discoverStorageConfig(new FakeDb(), input(), { command: 'rollback' }), /未找到可回滚/);
|
|
});
|
|
|
|
test('new content beyond a non-inventory run boundary blocks apply, including bigint boundaries', async () => {
|
|
const options = { runs: [run()], maximum: '9007199254740994' };
|
|
await assert.rejects(discoverStorageConfig(new FakeDb(options), input(), { command: 'apply' }), /新增内容/);
|
|
const planned = await discoverStorageConfig(new FakeDb(options), input(), { command: 'plan' });
|
|
assert.equal(planned.runId, 'original-run');
|
|
const inventoryDb = new FakeDb({ ...options, runs: [run('still-building', 'inventory')] });
|
|
const inventory = await discoverStorageConfig(inventoryDb, input(), { command: 'apply' });
|
|
assert.equal(inventory.runId, 'still-building');
|
|
assert.ok(!inventoryDb.calls.some(call => call.sql.includes('MAX(id)')));
|
|
});
|
|
|
|
test('missing source contents produce a clear no-migration result without creating a run', async () => {
|
|
const db = new FakeDb({ sourceIds: [] });
|
|
await assert.rejects(discoverStorageConfig(db, input()), /无需迁移/);
|
|
assert.ok(db.calls.every(call => /^SELECT\b/i.test(call.sql.trim())));
|
|
});
|