132 lines
6.1 KiB
JavaScript
132 lines
6.1 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { sourceCurrent } from '../migrate.mjs';
|
|
|
|
const config = { mysql: { database: 'source_current_test' } };
|
|
const item = {
|
|
contentId: '9007199254740993', configId: '9007199254740995',
|
|
path: '头像/Résumé + 100%.png', size: 1234, sha256: 'a'.repeat(64)
|
|
};
|
|
const current = overrides => ({ id: item.contentId, size: String(item.size), sha256: item.sha256, ...overrides });
|
|
|
|
class FakeDb {
|
|
constructor(responses) {
|
|
this.responses = responses;
|
|
this.calls = [];
|
|
}
|
|
async execute(sql, params) {
|
|
assert.match(sql, /^SELECT\b/, 'source validation must not write or manage the caller transaction');
|
|
this.calls.push({ sql, params });
|
|
if (this.calls.length > this.responses.length) throw new Error('Unexpected extra database query');
|
|
const response = this.responses[this.calls.length - 1];
|
|
if (response instanceof Error) throw response;
|
|
return [response];
|
|
}
|
|
}
|
|
|
|
function assertMetadataLookup(call, expectedItem, locked = false) {
|
|
assert.match(call.sql, /^SELECT id FROM /);
|
|
assert.doesNotMatch(call.sql, /\bcontent\b|SHA2\s*\(|OCTET_LENGTH\s*\(/i,
|
|
'the unindexed path/order lookup must not project file bytes or content expressions');
|
|
assert.match(call.sql, /WHERE deleted=0 AND config_id=\? AND path=\? ORDER BY id DESC LIMIT 1/);
|
|
assert.doesNotMatch(call.sql, /BINARY\s+path|LOWER\s*\(path\)/i, 'preserve native MySQL path collation');
|
|
assert.deepEqual(call.params, [expectedItem.configId, expectedItem.path]);
|
|
if (locked) assert.match(call.sql, / FOR UPDATE$/);
|
|
else assert.doesNotMatch(call.sql, /FOR UPDATE/i);
|
|
}
|
|
|
|
function assertPrimaryKeyHashRead(call, expectedItem, locked = false) {
|
|
assert.match(call.sql, /OCTET_LENGTH\(content\) AS size/);
|
|
assert.match(call.sql, /LOWER\(SHA2\(content,256\)\) AS sha256/);
|
|
assert.match(call.sql, /WHERE id=\? AND config_id=\? AND path=\? AND deleted=0/);
|
|
assert.doesNotMatch(call.sql, /ORDER BY|LIMIT|JOIN/i, 'read the digest through a single primary-key lookup');
|
|
assert.deepEqual(call.params, [expectedItem.contentId, expectedItem.configId, expectedItem.path]);
|
|
if (locked) assert.match(call.sql, / FOR UPDATE$/);
|
|
else assert.doesNotMatch(call.sql, /FOR UPDATE/i);
|
|
}
|
|
|
|
test('looks up the latest ID without BLOB expressions, then validates size and SHA by primary key', async () => {
|
|
const db = new FakeDb([[{ id: item.contentId }], [current()]]);
|
|
await sourceCurrent(db, config, item);
|
|
assert.equal(db.calls.length, 2);
|
|
assertMetadataLookup(db.calls[0], item);
|
|
assertPrimaryKeyHashRead(db.calls[1], item);
|
|
});
|
|
|
|
test('rejects a newer active version before any content/hash query, without rounding bigint IDs', async () => {
|
|
const db = new FakeDb([[{ id: '9007199254740994' }]]);
|
|
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
|
|
assert.equal(db.calls.length, 1);
|
|
assertMetadataLookup(db.calls[0], item);
|
|
});
|
|
|
|
test('rejects a missing latest record without issuing a content/hash query', async () => {
|
|
const db = new FakeDb([[]]);
|
|
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
|
|
assert.equal(db.calls.length, 1);
|
|
assertMetadataLookup(db.calls[0], item);
|
|
});
|
|
|
|
test('rejects content disappearing between the metadata and primary-key reads', async () => {
|
|
const db = new FakeDb([[{ id: item.contentId }], []]);
|
|
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
|
|
assert.equal(db.calls.length, 2);
|
|
assertPrimaryKeyHashRead(db.calls[1], item);
|
|
});
|
|
|
|
test('rejects changed length, changed hash, or an unexpected primary-key row', async t => {
|
|
for (const [name, row] of [
|
|
['changed size', current({ size: item.size + 1 })],
|
|
['changed hash with the same size', current({ sha256: 'b'.repeat(64) })],
|
|
['wrong content ID', current({ id: '9007199254740994' })],
|
|
['missing hash', current({ sha256: null })]
|
|
]) {
|
|
await t.test(name, async () => {
|
|
const db = new FakeDb([[{ id: item.contentId }], [row]]);
|
|
await assert.rejects(sourceCurrent(db, config, item), /源图片.*变化或已删除/);
|
|
assert.equal(db.calls.length, 2);
|
|
assertPrimaryKeyHashRead(db.calls[1], item);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('retains FOR UPDATE on both validation queries when the caller owns a transaction', async () => {
|
|
const db = new FakeDb([[{ id: item.contentId }], [current()]]);
|
|
await sourceCurrent(db, config, item, true);
|
|
assert.equal(db.calls.length, 2);
|
|
assertMetadataLookup(db.calls[0], item, true);
|
|
assertPrimaryKeyHashRead(db.calls[1], item, true);
|
|
});
|
|
|
|
test('locked validation still stops at the metadata lookup if the latest version changed', async () => {
|
|
const db = new FakeDb([[{ id: '9007199254740994' }]]);
|
|
await assert.rejects(sourceCurrent(db, config, item, true), /源图片.*变化或已删除/);
|
|
assert.equal(db.calls.length, 1);
|
|
assertMetadataLookup(db.calls[0], item, true);
|
|
});
|
|
|
|
test('supports unsigned-bigint-sized IDs and binds paths without interpolation or normalization', async () => {
|
|
const largeItem = { ...item, contentId: '18446744073709551615', path: "case/IMAGE.png' OR 'x'='x" };
|
|
const db = new FakeDb([[{ id: largeItem.contentId }], [current({ id: largeItem.contentId })]]);
|
|
await sourceCurrent(db, { mysql: { database: 'app`db' } }, largeItem, false);
|
|
assertMetadataLookup(db.calls[0], largeItem);
|
|
assertPrimaryKeyHashRead(db.calls[1], largeItem);
|
|
for (const call of db.calls) {
|
|
assert.match(call.sql, /`app``db`\.`infra_file_content`/);
|
|
assert.ok(!call.sql.includes(largeItem.contentId));
|
|
assert.ok(!call.sql.includes(largeItem.path));
|
|
}
|
|
});
|
|
|
|
test('propagates database failures instead of accepting an unverified source', async () => {
|
|
for (const responses of [
|
|
[Object.assign(new Error('metadata lookup timed out'), { code: 'TEST_TIMEOUT' })],
|
|
[[{ id: item.contentId }], Object.assign(new Error('hash read failed'), { code: 'TEST_READ_FAILURE' })]
|
|
]) {
|
|
const db = new FakeDb(responses);
|
|
const expected = responses[responses.length - 1];
|
|
await assert.rejects(sourceCurrent(db, config, item), error => error === expected);
|
|
assert.equal(db.calls.length, responses.length);
|
|
}
|
|
});
|