修复角色动作迁移分页安全

统一 HTTP 对象与 CLI tuple 的批次哈希字段

拒绝迁移 procedure 返回重复分页游标

补充对象返回和循环游标回归测试
This commit is contained in:
2026-08-05 13:25:14 +08:00
parent 5097580d79
commit 47743d086d
4 changed files with 83 additions and 3 deletions
+20 -1
View File
@@ -281,7 +281,7 @@ export async function assertReadableFile(filePath) {
function normalizeProcedureResult(value, procedureName) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
return normalizeSatsObject(value, procedureName);
}
if (Array.isArray(value)) {
@@ -291,6 +291,25 @@ function normalizeProcedureResult(value, procedureName) {
throw new Error('procedure 返回值不是对象。');
}
function normalizeSatsObject(value, procedureName) {
const normalized = normalizeSatsValue(value);
if (
procedureName !==
'normalize_editor_character_animation_metadata_and_return' ||
!normalized ||
typeof normalized !== 'object' ||
Array.isArray(normalized)
) {
return normalized;
}
const { batch_sha_256: batchSha256, ...result } = normalized;
return {
...result,
batch_sha256: result.batch_sha256 ?? batchSha256,
};
}
function normalizeSatsProduct(value, procedureName) {
if (
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
@@ -148,4 +148,30 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
error_message: null,
});
});
it('normalizes object-shaped character action procedure results', () => {
const result = parseProcedureResult(
JSON.stringify({
ok: true,
scope: 'asset',
dry_run: true,
scanned_count: 25,
matched_count: 3,
updated_count: 0,
blocker_count: 0,
blocker_ids: [],
next_cursor: 'asset-25',
has_more: true,
batch_sha_256: 'b'.repeat(64),
error_message: null,
}),
'normalize_editor_character_animation_metadata_and_return',
);
expect(result).toMatchObject({
batch_sha256: 'b'.repeat(64),
next_cursor: 'asset-25',
});
expect(result).not.toHaveProperty('batch_sha_256');
});
});
@@ -140,6 +140,7 @@ export async function scanScopes(
const summaries = [];
for (const scope of SCOPES) {
let cursor = null;
const seenCursors = new Set();
const summary = {
scope,
scanned_count: 0,
@@ -185,10 +186,17 @@ export async function scanScopes(
}
summary.updated_count += applied.updated_count;
}
cursor = dryRun.has_more ? dryRun.next_cursor : null;
if (dryRun.has_more && !cursor) {
const nextCursor = dryRun.has_more ? dryRun.next_cursor : null;
if (dryRun.has_more && !nextCursor) {
throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`);
}
if (nextCursor && seenCursors.has(nextCursor)) {
throw new Error(`${scope} scope 返回了重复的 next_cursor: ${nextCursor}`);
}
if (nextCursor) {
seenCursors.add(nextCursor);
}
cursor = nextCursor;
} while (cursor);
summaries.push(summary);
}
@@ -160,4 +160,31 @@ describe('角色动作元数据规范化脚本', () => {
}
expect(calls.at(-2)?.limit).toBe(5);
});
it('拒绝 procedure 返回已访问过的 cursor', async () => {
let callCount = 0;
const callProcedure = async () => {
callCount += 1;
const nextCursor = ['asset-25', 'asset-50', 'asset-25'][callCount - 1];
return {
ok: true,
scope: 'asset',
dry_run: true,
scanned_count: 1,
matched_count: 0,
updated_count: 0,
blocker_count: 0,
blocker_ids: [],
next_cursor: nextCursor,
has_more: true,
batch_sha256: 'a'.repeat(64),
error_message: null,
};
};
await expect(
scanScopes({ chunkSize: 25 }, { callProcedure }),
).rejects.toThrow('重复的 next_cursor: asset-25');
expect(callCount).toBe(3);
});
});