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

58 lines
7.0 KiB
JavaScript

/**
* 共享资源 depot、原始二进制传输及 Foundry authority 的单元测试。缓存降低读取成本,不能代替授权或身份验证。
* 控制权限和生命周期可检查迟到结果隔离;字节预算按实际流内容验证,不依赖声明长度。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createAssetDepot} from '../../src/lib/canvas-runtime/asset-depot.ts';
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
// 对真实 Blob 字节计算十六进制 SHA-256,用于检查缓存/服务引用与内容是否匹配。
const digest=async bytes=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
// 准备可替换缓存、权限及身份状态的 depot;resolve/read 分别计数,区分重新授权和重新下载。
async function fixture(){
const bytes=new Blob(['original'],{type:'image/png'}),scope={accountId:'account',workspaceId:'workspace'},leader={scope:{...scope,projectId:'p'},active:true},ref={version:1,authority:'authority',digest:await digest(bytes),asset:{id:'asset',url:'https://source.test/asset',width:2,height:1,bytes:bytes.size,mime_type:'image/png'}};
let cached,allowed=true,resolves=0,reads=0,current=true;
// port 仅提供可注入缓存;current 判断身份生命周期,digest 检查内容,collect 在此 fixture 中不做回收。
const depot=createAssetDepot({scope,port:{async read(){return cached;},async collect(){return{complete:true};}},current:()=>current,digest});
// 每次 resolve 都检查权限并返回引用,read 单独提供原始字节,便于断言热缓存仍授权。
const authority={scope:ref.authority,async resolve(){resolves++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;return bytes;}};
return{depot,scope,leader,ref,bytes,authority,setCached:v=>cached=v,deny:()=>allowed=false,retire:()=>current=false,counts:()=>({resolves,reads})};
}
// 首次接纳下载原字节,热缓存再次接纳只重新授权;撤权后不能以缓存绕过 resolve,读取次数保持不变。
test('new cross-project adoption reauthorizes even when immutable bytes are warm',async()=>{
const f=await fixture(),signal=new AbortController().signal;
assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);
// fixture 的缓存端口不会自行写入;显式放入上次字节来制造热缓存,再检查 resolve 仍调用而 read 不再调用。
f.setCached({ref:f.ref,bytes:f.bytes});assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);assert.deepEqual(f.counts(),{resolves:2,reads:1});
f.deny();await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),/denied/);assert.deepEqual(f.counts(),{resolves:3,reads:1});f.depot.close();
});
// depot 捕获作用域而不受外部 scope 改写影响;缓存身份冲突、字节篡改及当前身份失效分别拒绝。
test('depot captures scope, validates cached identity and bytes, and rejects revoked lifecycle',async()=>{
const f=await fixture(),signal=new AbortController().signal;f.scope.accountId='mutated';assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).ref.asset.id,'asset');
f.setCached({ref:{...f.ref,digest:'0'.repeat(64)},bytes:f.bytes});await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),{code:'shared_asset_identity_conflict'});
f.setCached({ref:f.ref,bytes:new Blob(['tampered'])});await assert.rejects(f.depot.read(f.leader,f.ref),{code:'shared_asset_bytes_changed'});
f.retire();await assert.rejects(f.depot.read(f.leader,f.ref),{code:'identity_changed'});f.depot.close();
});
// authority 结果返回前关闭 depot,迟到结果应以 identity_changed 拒绝,不能写入元数据缓存。
test('late authority result after close never enters the metadata cache',async()=>{
const f=await fixture();let done,started;const ready=new Promise(resolve=>started=resolve),wait=new Promise(resolve=>done=resolve);
const promise=f.depot.authorize(f.leader,{...f.authority,async resolve(){started();await wait;return f.ref;}},'asset',new AbortController().signal);await ready;f.depot.close();done();await assert.rejects(promise,{code:'identity_changed'});assert.equal(f.depot.getResources().size,0);
});
// 二进制响应保持原字节和 MIME;实际流超过 20 MiB 时取消读取,读取途中身份变化同样拒绝。
test('binary asset transport preserves original bytes and enforces identity and actual byte budgets',async()=>{
const accountId='11111111-1111-4111-8111-111111111111';let current=true,cancelled=false;
// 用指定 Response 替代 fetch,保留真实 transport 的流读取、大小限制及身份检查。
const transport=response=>createFoundryTransport({accountId,isCurrentIdentity:()=>current,fetch:async()=>response});
const result=await transport(new Response('original',{headers:{'content-type':'image/png'}}))({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'});assert.equal(await result.bytes.text(),'original');assert.equal(result.bytes.type,'image/png');
// 不给 Content-Length,直接推入超限流块,验证预算按实际接收字节而不是响应头声明执行。
const oversized=new Response(new ReadableStream({start(c){c.enqueue(new Uint8Array(20*1024*1024+1));},cancel(){cancelled=true;}}));await assert.rejects(transport(oversized)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'asset_byte_budget_exceeded'});assert.equal(cancelled,true);
const identityResponse=new Response(new ReadableStream({pull(c){current=false;c.enqueue(new Uint8Array([1]));}}));await assert.rejects(transport(identityResponse)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'identity_changed'});
});
// Foundry authority 走 metadata 和原字节两条请求路径,验证已支持的版本及 digest;未来元数据版本明确拒绝解释。
test('Foundry resource authority validates version, resource identity and canonical URL',async()=>{
const id='22222222-2222-4222-8222-222222222222',workspaceId='33333333-3333-4333-8333-333333333333';let version=1;const bytes=new Blob(['original']),sha256=await digest(bytes),calls=[];
const assets=createFoundryAssets({workspaceId,transport:async request=>{calls.push(request);return request.responseFormat==='asset-bytes-v1'?{status:200,body:null,bytes}:{status:200,body:{metadata_version:version,sha256,asset:{id,url:assets.sourceURL(id),width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}};}});
const ref=await assets.authority.resolve(id,new AbortController().signal);assert.equal(ref.digest,sha256);assert.equal(await assets.authority.read(id,new AbortController().signal),bytes);assert.equal(calls[0].path.includes('/metadata/'),true);assert.equal(calls[1].responseFormat,'asset-bytes-v1');version=2;await assert.rejects(assets.authority.resolve(id,new AbortController().signal),{code:'asset_metadata_interpreter_unavailable'});
});