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()))); });