import assert from 'node:assert/strict'; import {randomUUID} from 'node:crypto'; import {db} from '../src/lib/db'; import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto'; import type {CanvasDocument} from '../src/lib/canvas-document'; export async function verifyCanvasWorkspace(base:string){ const identities=await Promise.all([0,1].map(async()=>{const id=randomUUID(),token=newToken();await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[id,id+'@example.com']);await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),id]);return {[`cookie`]:ACCOUNT_COOKIE+'='+token}})); const origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base; const call=(path:string,method='GET',body?:unknown,headers:Record={})=>fetch(base+'/api/canvases'+path,{method,headers:{'Content-Type':'application/json',origin,...headers},...(body===undefined?{}:{body:JSON.stringify(body)})}); const doc={version:1,cards:[{id:'note',kind:'text',title:'简报',text:'自然光与城市清晨',x:0,y:0},{id:'script',kind:'script',title:'脚本',text:'',instruction:'写六个分镜',operation:'script',x:500,y:0}],links:[['note','script']]}; assert.equal((await call('','POST',{document:doc})).status,401); const create=await call('','POST',{document:doc},identities[0]);assert.equal(create.status,201,await create.clone().text());const canvas=await create.json();assert.equal(canvas.revision,1); const root='/'+canvas.id; assert.equal((await call(root,'GET',undefined,identities[1])).status,404); assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],origin:'https://evil.example'})).status,403); assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],authorization:'Bearer invalid',origin:'https://evil.example'})).status,404,'A bearer request cannot fall back to an owner cookie'); assert.equal((await call(root,'PUT',{revision:1,document:{...doc,links:[['note','script'],['script','note']]}},identities[0])).status,400,'Imported cyclic graphs are rejected'); const linked=await call(root+'/link','POST',{},identities[0]);assert.equal(linked.status,201);const credentials=await linked.json();const agent={authorization:'Bearer '+credentials.token}; assert.equal((await call('','GET',undefined,agent)).status,401); const other=await (await call('','POST',{document:doc},identities[0])).json();assert.equal((await call('/'+other.id,'GET',undefined,agent)).status,404,'A link cannot read another canvas'); assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,200); assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,409); async function queue(rev:number){const r=await call(root+'/tasks','POST',{revision:rev,targetId:'script'},identities[0]);assert.equal(r.status,201,await r.clone().text());return (await r.json()).id as string} const concurrent=await Promise.all([0,1].map(()=>call('/'+other.id,'PUT',{revision:1,document:doc},identities[0]))); assert.deepEqual(concurrent.map(r=>r.status).sort(),[200,409],'Concurrent saves cannot lose an update'); const first=await queue(2); assert.equal((await call(root+'/tasks','POST',{revision:2,targetId:'script'},identities[0])).status,409,'Only one live task per target'); const recipe=await (await call(root+'/tasks/'+first,'GET',undefined,agent)).json();assert.equal(recipe.request.references[0].text,doc.cards[0].text);assert.equal(recipe.request.instruction,'写六个分镜'); assert.equal((await call(root+'/tasks/'+first,'GET',undefined,identities[1])).status,404); const result={status:'succeeded',result:{text:'第一镜:城市醒来。'}}; assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Result cannot arrive before claim'); assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'running'},agent)).status,200); assert.equal((await call(root,'PUT',{revision:2,document:{...doc,cards:doc.cards.map(n=>({...n,x:n.x+10}))}},identities[0])).status,200); assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Stale result must not overwrite newer user edits'); assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'failed',error:'依据新版本重新派发'},identities[0])).status,200); const second=await queue(3);assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'running'},agent)).status,200); assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:{invalid:true}}},agent)).status,400,'Malformed results do not mutate the canvas'); assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{image:'data:image/png;base64,AAAA'}},agent)).status,400,'A script node cannot accept an image result'); assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200); assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200,'Identical completion is idempotent'); assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:'覆盖'}},agent)).status,409); const final=await (await call(root,'GET',undefined,identities[0])).json();assert.equal(final.revision,4);assert.equal(final.document.cards.find((n:{id:string})=>n.id==='script').text,'第一镜:城市醒来。');assert.deepEqual(final.document.links,doc.links); const png='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='; const imageDocument:CanvasDocument={version:1,cards:[ {id:'source',kind:'image',title:'参考图',text:'保留原图',image:png,x:0,y:0}, {id:'generated',kind:'image',title:'图生图',text:'',instruction:'保留构图,改为雪夜',operation:'generate',x:500,y:0}, {id:'upscaled',kind:'image',title:'图片高清',text:'',operation:'upscale',x:1000,y:0}, ],links:[['source','generated'],['generated','upscaled']]}; const imageCreate=await call('','POST',{document:imageDocument},identities[0]);assert.equal(imageCreate.status,201); const imageCanvas=await imageCreate.json(),imageRoot='/'+imageCanvas.id; const imageLink=await call(imageRoot+'/link','POST',{},identities[0]);assert.equal(imageLink.status,201); const imageAgent={authorization:'Bearer '+(await imageLink.json()).token}; for(const [index,targetId] of ['generated','upscaled'].entries()){ const queued=await call(imageRoot+'/tasks','POST',{revision:index+1,targetId},identities[0]);assert.equal(queued.status,201,await queued.clone().text()); const taskId=(await queued.json()).id,taskPath=imageRoot+'/tasks/'+taskId; const snapshotResponse=await call(taskPath,'GET',undefined,imageAgent);assert.equal(snapshotResponse.status,200); const snapshot=await snapshotResponse.json();assert.equal(snapshot.request.operation,index===0?'generate':'upscale'); assert.equal(snapshot.request.references[0].id,index===0?'source':'generated'); assert.equal(snapshot.request.references[0].image,png,'Image tasks carry the referenced image bytes'); assert.deepEqual(snapshot.request.result.allowedFields,['image','text']); assert.equal((await call(taskPath,'PATCH',{status:'running'},imageAgent)).status,200); const completion={status:'succeeded',result:{image:png,text:index===0?'雪夜图片结果':'高清图片结果'}}; const completed=await call(taskPath,'PATCH',completion,imageAgent);assert.equal(completed.status,200,await completed.clone().text()); assert.equal((await call(taskPath,'PATCH',completion,imageAgent)).status,200,'Image completion retries are idempotent'); const stateResponse=await call(imageRoot,'GET',undefined,identities[0]);assert.equal(stateResponse.status,200); const state=await stateResponse.json();assert.equal(state.revision,index+2); const cards=(state.document as CanvasDocument).cards; assert.equal(cards.find(n=>n.id===targetId)?.image,png);assert.equal(cards.find(n=>n.id===targetId)?.text,completion.result.text); assert.deepEqual(cards.find(n=>n.id==='source'),imageDocument.cards[0],'Generation preserves the reference node'); assert.deepEqual(state.document.links,imageDocument.links,'Image results preserve the reference graph'); if(index===0)assert.deepEqual(cards.find(n=>n.id==='upscaled'),imageDocument.cards[2],'Generation only updates its target'); else assert.equal(cards.find(n=>n.id==='generated')?.text,'雪夜图片结果','Upscaling preserves the previous generated image'); } assert.equal((await call(imageRoot+'/link','DELETE',{},identities[0])).status,200); assert.equal((await call(imageRoot,'GET',undefined,imageAgent)).status,404); assert.equal((await call(root+'/link','POST',{},agent)).status,403,'A delegated agent cannot mint links'); assert.equal((await call(root+'/link','DELETE',{},identities[0])).status,200); assert.equal((await call(root,'GET',undefined,agent)).status,404,'Revocation takes effect on the next request'); const rotated=await (await call(root+'/link','POST',{},identities[0])).json(); const expired={authorization:'Bearer '+rotated.token}; await db().query("UPDATE canvas_agent_links SET expires_at=now()-interval '1 second' WHERE canvas_id=$1",[canvas.id]); assert.equal((await call(root,'GET',undefined,expired)).status,404,'Expired capabilities are rejected'); const rotations=await Promise.all([call(root+'/link','POST',{},identities[0]),call(root+'/link','POST',{},identities[0])]); assert.ok(rotations.every(r=>r.status===201)); assert.equal((await db().query('SELECT count(*)::int AS count FROM canvas_agent_links WHERE canvas_id=$1',[canvas.id])).rows[0].count,1,'Concurrent rotation leaves one active capability'); const docs=await call(root+'?agent_mode=True');assert.equal(docs.status,200);assert.match(await docs.text(),/Authorization/); }