172 lines
10 KiB
JavaScript
172 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from '../lib/references.mjs';
|
|
|
|
const oldUrl = 'https://old.example/admin-api/infra/file/1/get/a.png';
|
|
const newUrl = 'https://bucket.example/migrated/a.png';
|
|
const otherUrl = 'https://elsewhere.example/a.png';
|
|
const replacements = new Map([[oldUrl, newUrl]]);
|
|
|
|
test('replaces an exact URL once and never cascades a destination mapping', () => {
|
|
assert.deepEqual(rewriteReferences(oldUrl, new Map([[oldUrl, newUrl], [newUrl, otherUrl]])), {
|
|
value: newUrl, changed: true, replacements: 1
|
|
});
|
|
assert.deepEqual(rewriteReferences(null, replacements), { value: null, changed: false, replacements: 0 });
|
|
});
|
|
|
|
test('replaces comma lists, repeated values and rich text while preserving surrounding text', () => {
|
|
const input = `${oldUrl},${otherUrl}, ${oldUrl}\n<a href="${oldUrl}"><img src=${oldUrl}></a>`;
|
|
const expected = `${newUrl},${otherUrl}, ${newUrl}\n<a href="${newUrl}"><img src=${newUrl}></a>`;
|
|
assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 4 });
|
|
assert.equal(rewriteReferences(`[image](${oldUrl}),`, replacements).value, `[image](${newUrl}),`);
|
|
});
|
|
|
|
test('never matches URL prefixes, query extensions, fragments or external embedded URLs', () => {
|
|
const unchanged = [
|
|
`${oldUrl}.bak`, `${oldUrl}/thumbnail`, `${oldUrl}?width=200`, `${oldUrl}#preview`,
|
|
`${oldUrl}%20other`, `${oldUrl}&x=1`, `${oldUrl};v=1`, otherUrl,
|
|
`https://external.example/?redirect=${oldUrl}`,
|
|
`https://external.example/?redirect=x,${oldUrl}`,
|
|
`${oldUrl}?url=x,${oldUrl}`
|
|
];
|
|
for (const value of unchanged) {
|
|
assert.deepEqual(rewriteReferences(value, replacements), { value, changed: false, replacements: 0 }, value);
|
|
}
|
|
});
|
|
|
|
test('only rewrites queries and fragments explicitly present in the dictionary', () => {
|
|
const url = `${oldUrl}?v=2#original`;
|
|
assert.equal(rewriteReferences(`<img src="${url}">`, new Map([[url, newUrl]])).value, `<img src="${newUrl}">`);
|
|
assert.equal(rewriteReferences(url, replacements).value, url);
|
|
});
|
|
|
|
test('keeps URL encoding literal and supports Unicode paths', () => {
|
|
const encoded = oldUrl.replace('a.png', '%E5%A4%B4%E5%83%8F%20a.png');
|
|
const unicode = oldUrl.replace('a.png', '头像.png');
|
|
const mapping = new Map([[encoded, newUrl], [unicode, `${newUrl}?kind=unicode`]]);
|
|
assert.equal(rewriteReferences(`<img src="${encoded}"> ${unicode}`, mapping).replacements, 2);
|
|
assert.equal(rewriteReferences(encoded.replace('%20', ' '), mapping).changed, false);
|
|
assert.equal(rewriteReferences(encoded.toLowerCase(), mapping).changed, false);
|
|
});
|
|
|
|
test('JSON text retains formatting and untouched escapes; keys remain unchanged', () => {
|
|
const escapedUrl = oldUrl.replaceAll('/', '\\/');
|
|
const input = `{\n "${oldUrl}": "key stays", "avatar" : "${escapedUrl}",\n "nested": [{"pic":"${oldUrl}"}], "untouched":"\\u4e2d"\n}`;
|
|
const expected = `{\n "${oldUrl}": "key stays", "avatar" : "${newUrl}",\n "nested": [{"pic":"${newUrl}"}], "untouched":"\\u4e2d"\n}`;
|
|
assert.deepEqual(rewriteReferences(input, replacements), { value: expected, changed: true, replacements: 2 });
|
|
assert.equal(rewriteReferences(JSON.stringify(JSON.stringify({ avatar: oldUrl })), replacements).value,
|
|
JSON.stringify(JSON.stringify({ avatar: newUrl })));
|
|
});
|
|
|
|
test('parsed JSON is recursively rewritten without changing input or prototype', () => {
|
|
const input = JSON.parse(`{"__proto__":{"url":"${oldUrl}"},"photos":["${oldUrl}",null,4],"url":"${otherUrl}"}`);
|
|
const result = rewriteReferences(input, replacements);
|
|
assert.equal(result.replacements, 2);
|
|
assert.equal(input.photos[0], oldUrl);
|
|
assert.equal(result.value.photos[0], newUrl);
|
|
assert.equal(result.value.__proto__.url, newUrl);
|
|
assert.equal(Object.getPrototypeOf(result.value), Object.prototype);
|
|
assert.equal(rewriteReferences({ url: otherUrl }, replacements).changed, false);
|
|
});
|
|
|
|
test('malformed JSON safely uses literal URL replacement without changing escapes', () => {
|
|
const input = `{"avatar":"${oldUrl}",}`;
|
|
assert.equal(rewriteReferences(input, replacements).value, `{"avatar":"${newUrl}",}`);
|
|
});
|
|
|
|
test('remaining-reference detection finds Unicode-escaped JSON URLs with unknown queries', () => {
|
|
const withQuery = `${oldUrl}?unmapped=1#preview`;
|
|
const unicodeJson = JSON.stringify({ avatar: withQuery }).replaceAll('/', '\\u002f');
|
|
const rewritten = rewriteReferences(unicodeJson, replacements);
|
|
assert.equal(rewritten.changed, false);
|
|
assert.equal(containsLegacyReference(rewritten.value, ['1']), true);
|
|
assert.equal(containsLegacyReference(rewritten.value, ['2']), false);
|
|
});
|
|
|
|
test('remaining-reference detection recursively handles nested JSON strings and parsed values', () => {
|
|
const nested = JSON.stringify({ data: JSON.stringify({ photos: [`${oldUrl}?v=2`] }).replaceAll('/', '\\u002f') });
|
|
assert.equal(containsLegacyReference(nested, new Set([1])), true);
|
|
assert.equal(containsLegacyReference({ data: [null, { image: oldUrl }] }, [1]), true);
|
|
assert.equal(containsLegacyReference(JSON.stringify(JSON.stringify(`${oldUrl}#unknown`)), ['1']), true);
|
|
assert.equal(containsLegacyReference(rewriteReferences(JSON.stringify({ image: oldUrl }), replacements).value, [1]), false);
|
|
});
|
|
|
|
test('remaining-reference detection handles escaped plain text, exact config IDs, and excludes JSON keys', () => {
|
|
assert.equal(containsLegacyReference(`<img src="${oldUrl.replaceAll('/', '\\/')}?size=2">`, [1]), true);
|
|
assert.equal(containsLegacyReference(`${oldUrl}?query=2`, [11]), false);
|
|
assert.equal(containsLegacyReference(oldUrl.replace('/file/1/', '/file/11/'), [1]), false);
|
|
assert.equal(containsLegacyReference(JSON.stringify({ [oldUrl]: 'not a URL value' }), [1]), false);
|
|
assert.equal(containsLegacyReference({ [oldUrl]: 'not a URL value' }, [1]), false);
|
|
assert.equal(containsLegacyReference(null, [1]), false);
|
|
assert.equal(containsLegacyReference(oldUrl, []), false);
|
|
const cycle = {};
|
|
cycle.self = cycle;
|
|
assert.equal(containsLegacyReference(cycle, [1]), false);
|
|
});
|
|
|
|
test('discovery binds schema names and excludes unsafe or non-content columns', async () => {
|
|
const row = (tableName, columnName, extra = {}) => ({
|
|
schemaName: 'app', tableName, columnName, dataType: 'varchar', extra: '',
|
|
generationExpression: '', primaryKey: 'id', primaryKeyCount: 1, ...extra
|
|
});
|
|
const rows = [
|
|
row('member_user', 'avatar'), row('member_user', 'id'),
|
|
row('member_user', 'generated', { generationExpression: 'avatar', extra: 'VIRTUAL GENERATED' }),
|
|
row('member_user', 'stored', { extra: 'STORED GENERATED' }),
|
|
row('member_user', 'seq', { extra: 'auto_increment' }),
|
|
row('member_user', 'defaulted', { extra: 'DEFAULT_GENERATED' }),
|
|
row('member_com_auth', 'auth_extra_json', { dataType: 'json' }),
|
|
row('infra_file', 'url'), row('infra_file_content', 'path'), row('infra_file_config', 'config'),
|
|
row('migration_files', 'old_url'), row('infra_file_migration_journal', 'old_url'),
|
|
row('infra_file_oss_migration_runs', 'settings'), row('infra_file_oss_migration_items', 'data'),
|
|
row('infra_file_oss_migration_changes', 'before_json'), row('INFRA_FILE_OSS_MIGRATION_CHANGES', 'after_json'),
|
|
row('no_key', 'a', { primaryKeyCount: null, primaryKey: null }),
|
|
row('no_key', 'b', { primaryKeyCount: null, primaryKey: null }),
|
|
row('composite', 'a', { primaryKeyCount: '2' }),
|
|
row('skip_me', 'a'), row('skip_qualified', 'a'),
|
|
];
|
|
const schemas = ['app', 'odd` schema; DROP TABLE x'];
|
|
const connection = { async execute(sql, params) {
|
|
assert.deepEqual(params, [...schemas, ...schemas]);
|
|
assert.ok(!sql.includes(schemas[1]));
|
|
assert.match(sql, /t\.TABLE_TYPE = 'BASE TABLE'/);
|
|
assert.match(sql, /c\.DATA_TYPE IN \('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json'\)/);
|
|
return [rows];
|
|
} };
|
|
const result = await discoverReferenceColumns(connection, schemas, new Set(['skip_me', 'APP.skip_qualified']));
|
|
assert.deepEqual(result.columns, [
|
|
{ schema: 'app', table: 'member_user', column: 'avatar', primaryKey: 'id', dataType: 'varchar' },
|
|
{ schema: 'app', table: 'member_user', column: 'defaulted', primaryKey: 'id', dataType: 'varchar' },
|
|
{ schema: 'app', table: 'member_com_auth', column: 'auth_extra_json', primaryKey: 'id', dataType: 'json' }
|
|
]);
|
|
assert.equal(result.skipped.length, 13);
|
|
for (const table of ['infra_file_oss_migration_runs', 'infra_file_oss_migration_items', 'infra_file_oss_migration_changes', 'INFRA_FILE_OSS_MIGRATION_CHANGES']) {
|
|
assert.equal(result.skipped.find((item) => item.table === table).reason, 'excluded table');
|
|
assert.ok(!result.columns.some((item) => item.table === table));
|
|
}
|
|
assert.equal(result.skipped.find((item) => item.table === 'no_key').reason, 'no primary key');
|
|
assert.equal(result.skipped.find((item) => item.table === 'composite').reason, 'composite primary key');
|
|
});
|
|
|
|
test('discovery makes no connection for an empty schema list and validates names', async () => {
|
|
assert.deepEqual(await discoverReferenceColumns({}, []), { columns: [], skipped: [] });
|
|
await assert.rejects(discoverReferenceColumns({}, ['']), /schema names/);
|
|
});
|
|
|
|
test('discovery explicitly excludes known technical and audit logs while retaining business log tables', async () => {
|
|
const excluded = [
|
|
'infra_api_access_log', 'infra_api_error_log', 'infra_job_log',
|
|
'system_operate_log', 'system_login_log', 'SYSTEM_LOGIN_LOG',
|
|
];
|
|
const retained = ['member_activity_log', 'chat_message_log', 'custom_audit_log', 'infra_api_access_log_archive'];
|
|
const rows = [...excluded, ...retained].flatMap(tableName => ['content', 'details'].map(columnName => ({
|
|
schemaName: 'app', tableName, columnName, dataType: 'text', extra: '',
|
|
generationExpression: '', primaryKey: 'id', primaryKeyCount: 1,
|
|
})));
|
|
const connection = { async execute() { return [rows]; } };
|
|
const result = await discoverReferenceColumns(connection, ['app']);
|
|
assert.deepEqual(result.skipped, excluded.map(table => ({ schema: 'app', table, reason: 'excluded table' })));
|
|
assert.deepEqual([...new Set(result.columns.map(column => column.table))], retained);
|
|
assert.equal(result.columns.length, retained.length * 2);
|
|
});
|