Files
Endoleap/tests/headless/asset-service.test.mjs
T

73 lines
6.6 KiB
JavaScript

/**
* 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);
});