Files
Sybil-2/server/tests/agent-attachments.test.ts
T

111 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { once } from 'node:events';
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import test from 'node:test';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
process.env.AGENT_PUSH_ENABLED = 'false';
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
const directory = mkdtempSync(join(tmpdir(), 'sybil-attachment-proxy-'));
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
process.env.DATABASE_URL = `file:${join(directory, 'test.db')}`;
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
const bodies: any[] = [];
let capable=true, assistantImages=false, badReceipt=false, errorStatus=0, uploadCalls=0;
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
const source=createServer(async(req,res)=>{
assert.equal(req.headers.authorization,`Bearer ${key}`);
const chunks: Buffer[]=[];
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',capabilities:{...(capable?{attachments:{version:1}}:{}),...(assistantImages?{assistantImages:{version:1}}:{})}}));return;}
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;}
res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false}));
});
source.listen(0,'127.0.0.1');await once(source,'listening');
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));const {prisma}=await import('../src/db.js');await prisma.$disconnect();rmSync(directory,{recursive:true,force:true});});
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
const before=uploadCalls;
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before);
capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503);
capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined;
try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;}
});
test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{
const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/);
errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;}
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff');
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
});
test('assistant-only capability allows authenticated retrieval without granting input uploads or open history',async()=>{
capable=false;assistantImages=true;
const saved=env.ADMIN_TOKEN;
try {
const descriptor=(await app.inject({url:'/v1/agent',headers:auth})).json();
assert.deepEqual(descriptor.capabilities,{assistantImages:{version:1}});
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'assistant-only',attachments:[{id:file.id}]}})).statusCode,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/output_image',headers:auth,payload:{path:'https://never-fetch.invalid/image'}})).statusCode,404);
env.ADMIN_TOKEN=undefined;
for(const url of ['/v1/agent','/v1/agent/messages',`/v1/agent/attachments/${file.id}/content`]) assert.equal((await app.inject({url})).statusCode,503);
} finally {env.ADMIN_TOKEN=saved;capable=true;assistantImages=false;}
});
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;}
for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400);
const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool');
});
test('proxy rejects oversized declared bodies and chunked bodies',async()=>{
const before=uploadCalls;
const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before);
const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})());
const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string});
assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded');
});
async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()<end)await new Promise(r=>setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');}
test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{
const before=uploadCalls;
holdDescriptor=true;
for(let n=0;n<3;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--');
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload');
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201);
holdDownload=true;
for(let n=0;n<5;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end();
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
});