import { chmod, mkdir, mkdtemp, rm, symlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { buildProcedureInput, executeRepairPlan, parseOptions, readRepairPlan, runCli, validateRepairPlan, } from './spacetime-repair-editor-canvas-resources.mjs'; const SHA_A = 'a'.repeat(64); const SHA_B = 'b'.repeat(64); const PLAN_SHA = 'c'.repeat(64); const temporaryRoots: string[] = []; afterEach(async () => { process.exitCode = undefined; await Promise.all( temporaryRoots .splice(0) .map((root) => rm(root, { recursive: true, force: true })), ); }); function action(overrides: Record = {}) { return { action_kind: 'reuse_project_resource', layer_id: 'private-layer-id', expected_resource_id: 'missing-private-resource-id', replacement_resource_id: 'replacement-private-resource-id', expected_layout_source_resource_id: 'source-private-resource-id', asset_object_id: null, asset_kind: null, ...overrides, }; } function canvas(overrides: Record = {}) { return { canvas_id: 'private-canvas-id', project_id: 'private-project-id', owner_user_id: 'private-owner-id', expected_revision: 7, expected_canvas_layout_sha256: SHA_A, expected_project_layout_sha256: SHA_B, actions: [action()], ...overrides, }; } function planValue(canvases = [canvas()]) { return { schema_version: 1, expected_canvas_count: canvases.length, expected_action_count: canvases.reduce( (sum, item) => sum + (Array.isArray(item.actions) ? item.actions.length : 0), 0, ), canvases, }; } function executionOptions(overrides: Record = {}) { return { apply: false, confirmPlanSha256: '', database: 'genarrative-prod', passthrough: [], planFile: '/outside/repair-plan.json', server: 'http://127.0.0.1:3101', serverUrl: '', ...overrides, }; } function procedureResult(overrides: Record = {}) { return { ok: true, repair: { dry_run: true, already_repaired: false, matched_layer_count: 1, remapped_layer_count: 1, restored_resource_count: 0, removed_source_resource_id_count: 1, revision: 7, }, error_message: null, ...overrides, }; } describe('spacetime editor canvas resource repair options', () => { it('defaults to dry-run and requires an explicit apply flag', () => { const options = parseOptions([ '--database', 'genarrative-prod', '--server-url', 'http://127.0.0.1:3101', '--plan-file', '/tmp/repair-plan.json', ]); expect(options.apply).toBe(false); expect(options.confirmPlanSha256).toBe(''); expect(options.planFile).toBe('/tmp/repair-plan.json'); }); it('records explicit apply and lowercases the confirmed plan hash', () => { const options = parseOptions([ '--apply', '--confirm-plan-sha256', SHA_A.toUpperCase(), ]); expect(options.apply).toBe(true); expect(options.confirmPlanSha256).toBe(SHA_A); }); it('rejects anonymous procedure calls', () => { expect(() => parseOptions(['--anonymous'])).toThrow('未知参数'); }); }); describe('spacetime editor canvas resource repair plan', () => { it('validates and normalizes the two supported action kinds', () => { const plan = validateRepairPlan( planValue([ canvas({ actions: [ action(), action({ action_kind: 'restore_project_resource', layer_id: 'private-audio-layer-id', expected_resource_id: 'missing-private-audio-resource-id', replacement_resource_id: null, expected_layout_source_resource_id: null, asset_object_id: 'private-asset-object-id', asset_kind: 'background-music', }), ], }), ]), ); expect(plan.expected_action_count).toBe(2); expect(plan.canvases[0].actions).toMatchObject([ { action_kind: 'reuse_project_resource' }, { action_kind: 'restore_project_resource', asset_kind: 'background-music', }, ]); }); it('rejects count mismatches, duplicate layers, and invalid kind-specific fields', () => { expect(() => validateRepairPlan({ ...planValue(), expected_action_count: 2 }), ).toThrow('expected_action_count'); expect(() => validateRepairPlan( planValue([ canvas({ actions: [action(), action({ replacement_resource_id: 'other' })], }), ]), ), ).toThrow('layer_id 在同一画布内重复'); expect(() => validateRepairPlan( planValue([ canvas({ actions: [ action({ action_kind: 'restore_project_resource', replacement_resource_id: null, expected_layout_source_resource_id: null, asset_object_id: 'private-asset-object-id', asset_kind: 'image', }), ], }), ]), ), ).toThrow('asset_kind 只支持'); }); it('reads only an external current-user 0600 regular plan and returns its hash', async () => { const root = await makeTemporaryRoot(); const planPath = path.join(root, 'repair-plan.json'); await writeFile(planPath, `${JSON.stringify(planValue())}\n`, 'utf8'); await chmod(planPath, 0o600); const result = await readRepairPlan(planPath); expect(result.plan.expected_canvas_count).toBe(1); expect(result.planSha256).toMatch(/^[0-9a-f]{64}$/u); }); it('rejects permissive modes, repository paths, and symlink path components', async () => { const root = await makeTemporaryRoot(); const planPath = path.join(root, 'repair-plan.json'); await writeFile(planPath, JSON.stringify(planValue()), 'utf8'); await chmod(planPath, 0o644); await expect(readRepairPlan(planPath)).rejects.toThrow('0600'); await chmod(planPath, 0o600); await expect(readRepairPlan(planPath, { repoRoot: root })).rejects.toThrow( '必须位于仓库外', ); const realDirectory = path.join(root, 'real'); const linkedDirectory = path.join(root, 'linked'); await mkdir(realDirectory); const linkedPlanPath = path.join(realDirectory, 'linked-plan.json'); await writeFile(linkedPlanPath, JSON.stringify(planValue()), 'utf8'); await chmod(linkedPlanPath, 0o600); await symlink(realDirectory, linkedDirectory); await expect( readRepairPlan(path.join(linkedDirectory, 'linked-plan.json')), ).rejects.toThrow('路径链不能包含符号链接'); }); }); describe('spacetime editor canvas resource repair execution', () => { it('splits the two action kinds into typed procedure inputs', () => { const plan = validateRepairPlan( planValue([ canvas({ actions: [ action(), action({ action_kind: 'restore_project_resource', layer_id: 'private-audio-layer-id', expected_resource_id: 'missing-private-audio-resource-id', replacement_resource_id: null, expected_layout_source_resource_id: null, asset_object_id: 'private-asset-object-id', asset_kind: 'sound-effect', }), ], }), ]), ); const input = buildProcedureInput(plan.canvases[0], 123, true); expect(input.remaps).toEqual([ { layer_id: 'private-layer-id', expected_missing_resource_id: 'missing-private-resource-id', replacement_resource_id: 'replacement-private-resource-id', expected_layout_source_resource_id: 'source-private-resource-id', }, ]); expect(input.restores).toEqual([ { layer_id: 'private-audio-layer-id', resource_id: 'missing-private-audio-resource-id', asset_object_id: 'private-asset-object-id', asset_kind: 'sound-effect', }, ]); expect(input.canvas_id).toBe('private-canvas-id'); expect(input.updated_at_micros).toBe(123); expect(input.dry_run).toBe(true); }); it('calls one procedure per canvas and emits only anonymous aggregate output', async () => { const plan = validateRepairPlan( planValue([ canvas(), canvas({ canvas_id: 'second-private-canvas-id', project_id: 'second-private-project-id', actions: [ action({ layer_id: 'second-private-layer-id', expected_resource_id: 'second-private-resource-id', replacement_resource_id: 'second-private-replacement-id', }), ], }), ]), ); const calls: unknown[] = []; const summary = await executeRepairPlan({ options: executionOptions(), plan, planSha256: PLAN_SHA, updatedAtMicros: 123, callProcedure: async (options, procedureName, input) => { calls.push({ options, procedureName, input }); return procedureResult(); }, }); expect(calls).toHaveLength(2); expect(calls).toMatchObject([ { procedureName: 'repair_editor_canvas_resources_and_return' }, { procedureName: 'repair_editor_canvas_resources_and_return' }, ]); expect(summary).toMatchObject({ ok: true, dry_run: true, canvas_count: 2, action_count: 2, processed_canvas_count: 2, matched_layer_count: 2, updated_layer_count: 2, inserted_resource_count: 0, }); const publicOutput = JSON.stringify(summary); for (const privateValue of [ 'private-project-id', 'private-layer-id', 'missing-private-resource-id', 'replacement-private-resource-id', ]) { expect(publicOutput).not.toContain(privateValue); } }); it('requires apply to match the dry-run plan hash', async () => { const plan = validateRepairPlan(planValue()); const callProcedure = async () => procedureResult(); await expect( executeRepairPlan({ options: executionOptions({ apply: true, confirmPlanSha256: SHA_A }), plan, planSha256: PLAN_SHA, callProcedure, }), ).rejects.toThrow('与当前 plan 不一致'); }); it('stops apply at the first failed canvas without exposing procedure messages', async () => { const plan = validateRepairPlan( planValue([ canvas(), canvas({ canvas_id: 'second-private-canvas-id', project_id: 'second-private-project-id', actions: [action({ layer_id: 'second-private-layer-id' })], }), ]), ); let callCount = 0; await expect( executeRepairPlan({ options: executionOptions({ apply: true, confirmPlanSha256: PLAN_SHA, }), plan, planSha256: PLAN_SHA, callProcedure: async () => { callCount += 1; return procedureResult({ ok: false, repair: null, error_message: 'contains private-project-id and private-resource-id', }); }, }), ).rejects.toThrow( '第 1 个画布修复失败,error_code=repair_rejected;此前成功处理 0 个画布', ); expect(callCount).toBe(1); }); it('continues dry-run validation and reports only ordinal-safe failure codes', async () => { const plan = validateRepairPlan( planValue([ canvas(), canvas({ canvas_id: 'second-private-canvas-id', project_id: 'second-private-project-id', actions: [action({ layer_id: 'second-private-layer-id' })], }), ]), ); let callCount = 0; const summary = await executeRepairPlan({ options: executionOptions(), plan, planSha256: PLAN_SHA, callProcedure: async () => { callCount += 1; return callCount === 1 ? procedureResult({ ok: false, repair: null, error_message: 'contains private-project-id', }) : procedureResult(); }, }); expect(callCount).toBe(2); expect(summary).toMatchObject({ ok: false, processed_canvas_count: 1, failures: [{ canvas_ordinal: 1, error_code: 'repair_rejected' }], }); expect(JSON.stringify(summary)).not.toContain('private-project-id'); }); it('automatically dry-runs the same plan after apply and requires already repaired', async () => { const plan = validateRepairPlan(planValue()); const originalLog = console.log; console.log = () => {}; let callCount = 0; try { const summary = await runCli( [ '--database', 'genarrative-prod', '--server-url', 'http://127.0.0.1:3101', '--plan-file', '/outside/repair-plan.json', '--apply', '--confirm-plan-sha256', PLAN_SHA, ], { readPlan: async () => ({ plan, planSha256: PLAN_SHA }), callProcedure: async (_options, _procedureName, input) => { callCount += 1; const dryRun = input.dry_run === true; return procedureResult({ repair: { ...procedureResult().repair, dry_run: dryRun, already_repaired: dryRun, remapped_layer_count: dryRun ? 0 : 1, removed_source_resource_id_count: dryRun ? 0 : 1, revision: 8, }, }); }, updatedAtMicros: 123, }, ); expect(callCount).toBe(2); expect(summary).toMatchObject({ ok: true, dry_run: false }); } finally { console.log = originalLog; } }); }); async function makeTemporaryRoot() { const root = await mkdtemp(path.join(tmpdir(), 'editor-canvas-repair-')); temporaryRoots.push(root); return root; }