/** * Foundry 上传 adapter 与 multipart transport 的协议测试。冻结请求关联资源 ID、操作 ID、摘要及字节长度。 * 回执未知或权限失败不等于零写入,显示地址可由宿主路由,但 canonical 身份和 authority 必须保持。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createFoundryAssets, createFoundryTransport } from '../../src/lib/canvas-service/index.ts'; // 生成固定格式 UUID,满足 adapter 的身份校验且让不同角色的 ID 易于区分。 const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`; // 构造真实 Blob、摘要、冻结上传 body 与匹配成功回执;respond 只替换模拟服务端响应。 async function fixture() { const blob = new Blob(['immutable image'], { type: 'image/png' }); const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join(''); const resource = { id: id(1), operationId: id(2), name: 'picture.png', byteLength: blob.size, mimeType: blob.type, digest }; // 上传没有 canvas writer,但仍拥有 requestId/operationId 和不可变协议字段,不能重试时换身份。 const request = { kind: 'asset.upload', protocolVersion: 2, id: id(3), operationIds: [resource.operationId], canvasIds: [], expectedVersions: {}, phase: 'frozen', outcome: 'unresolved', generation: 1, attempt: 0, body: JSON.stringify({ protocol_version: 2, request_id: id(3), resource_id: resource.id, operation_id: resource.operationId, name: resource.name, mime_type: resource.mimeType, byte_length: resource.byteLength, sha256: resource.digest }) }; // 成功回执使用当前 workspace 的规范 asset URL;测试会分别篡改资源身份、大小、地址和尺寸。 const calls = [], receipt = { protocol_version: 2, request_id: request.id, status: 'applied', asset: { id: resource.id, bytes: resource.byteLength, mime_type: 'image/png', width: 2, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${id(4)}/assets/${resource.id}` } }; let response = { status: 201, body: receipt }; const assets = createFoundryAssets({ workspaceId: id(4), transport: async input => { calls.push(input); return response; } }); return { blob, resource, request, calls, receipt, assets, respond: next => { response = next; } }; } // 上传保持文件名、原字节及请求身份;accept 拒绝不匹配回执,换字节重试在发请求前就被阻塞。 test('asset adapter preserves byte identity and headers and validates the scoped applied receipt', async () => { const f = await fixture(), result = await f.assets.upload(f.request, f.blob, new AbortController().signal); assert.equal(result.outcome, 'applied'); assert.equal(f.calls[0].requestId, f.request.id); assert.equal(f.calls[0].resourceId, f.resource.id); assert.equal(f.calls[0].body.get('image').name, f.resource.name); assert.equal(await f.calls[0].body.get('image').text(), await f.blob.text()); assert.equal(f.assets.accept(f.request, result, f.resource).id, f.resource.id); for (const patch of [{ id: id(99) }, { bytes: 100 }, { url: 'https://other.example/image' }, { width: 0 }, { width: 1000000, height: 1000000 }]) { const altered = { ...result, canonical: { ...f.receipt, asset: { ...f.receipt.asset, ...patch } } }; assert.throws(() => f.assets.accept(f.request, altered, f.resource), /asset_receipt_mismatch/); } // different 的长度也不同,因此这里证明预发送校验拒绝了不匹配输入;不是单独隔离“等长但摘要变化”的样本。 const changed = await f.assets.upload(f.request, new Blob(['different']), new AbortController().signal); assert.equal(changed.outcome, 'unresolved'); assert.equal(changed.blocked, true); assert.equal(f.calls.length, 1); }); // 宿主只替换 displayURL,不能改变 sourceURL 或 authority;单纯计算显示路径也不能触发网络。 test('a headless host supplies display URLs without changing canonical asset identity or authority',async()=>{ const options={workspaceId:id(4),transport:async()=>{throw Error('display routing must not make a request');}}; const browser=createFoundryAssets(options),host=createFoundryAssets({...options,displayURL:asset=>`https://host.example/preview/${encodeURIComponent(asset)}`}); assert.equal(browser.displayURL(id(1)),`/api/foundry/workspaces/${id(4)}/assets/${id(1)}?variant=preview`); assert.equal(host.displayURL(id(1)),`https://host.example/preview/${id(1)}`); assert.equal(host.sourceURL(id(1)),browser.sourceURL(id(1)));assert.equal(host.authority.scope,browser.authority.scope); }); // 查询 404 unknown 保持 unresolved、403 阻塞自动处理,成功响应若 requestId 错误仍不能当作已应用证明。 test('missing and rejected upload receipts never become proof of zero writes', async () => { const f = await fixture(), signal = new AbortController().signal; f.respond({ status: 404, body: { protocol_version: 2, request_id: f.request.id, status: 'unknown' } }); const unknown = await f.assets.query(f.request, signal); assert.equal(unknown.outcome, 'unresolved'); assert.equal(unknown.blocked, undefined); assert.ok(f.calls[0].path.endsWith(`/assets/operations/${f.request.id}`)); f.respond({ status: 403, body: { error: 'forbidden' } }); assert.equal((await f.assets.query(f.request, signal)).blocked, true); f.respond({ status: 201, body: { ...f.receipt, request_id: id(99) } }); assert.equal((await f.assets.upload(f.request, f.blob, signal)).outcome, 'unresolved'); }); // FormData 保持原对象交给 fetch,由 fetch 生成 multipart boundary;同时传入账户、资源和幂等身份头。 test('multipart browser transport leaves the boundary to fetch and binds the expected account', async () => { let captured; const transport = createFoundryTransport({ accountId: id(5), isCurrentIdentity: () => true, fetch: async (url, init) => { captured = { url, init }; return Response.json({ status: 'applied' }); } }); const body = new FormData(); body.append('image', new Blob(['image']), 'a.png'); await transport({ method: 'POST', path: 'workspaces/example/assets', body, requestId: id(3), resourceId: id(1) }); // fetch 是捕获参数的替身,没有真正编码/发送 multipart;断言要求宿主不手写 Content-Type,保留平台生成 boundary 的入口。 const headers = new Headers(captured.init.headers); assert.equal(headers.has('content-type'), false); assert.equal(headers.get('X-Expected-Account'), id(5)); assert.equal(headers.get('X-Resource-Id'), id(1)); assert.equal(headers.get('Idempotency-Key'), id(3)); assert.equal(captured.init.body, body); });