Files

88 lines
9.6 KiB
TypeScript

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<string,string>={})=>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/);
}