Files
safetynet/tests/agent-individual-transport.test.mjs
T

24 lines
8.5 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url),{startAgentControl}=require('../apps/browser/agent-control.cjs'),{requestAgent,authorization}=require('../apps/browser/agent-transport-client.cjs'),channel=require('../packages/core/policy-channel.cjs');
const grant=(id,more={})=>({id,generation:1,enabled:true,expiresAt:new Date(Date.now()+60000).toISOString(),scopes:['sessions:list'],...more});
async function fixture(t,extra={}){const grants=[grant('agent.one'),grant('agent-two')],events=[];const api=await startAgentControl({principalMode:'individual',principals:grants,authorize:async({scope,principalId,generation})=>({allowed:!!principalId&&generation===1,role:'agent',scopes:[scope]}),actions:{sessions:async()=>[{id:'work'}]},audit:async e=>events.push(e),...extra});t.after(()=>api.close());return {api,grants,events,one:api.issuePrincipal(...[grants[0].id,1,grants[0].expiresAt]),two:api.issuePrincipal(...[grants[1].id,1,grants[1].expiresAt])};}
const send=(c,route,body='{}')=>fetch(`${c.address}${route}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:authorization(c.token,route,body,c)},body});
test('individual principals have separate capabilities, authenticated identity and no shared master',async t=>{let persisted=false;const f=await fixture(t,{persistCredential:()=>{persisted=true;}});assert.equal(f.api.token,undefined);assert.equal(persisted,false);assert.notEqual(f.one.token,f.two.token);for(const c of [f.one,f.two]){const response=await requestAgent(c,{action:'sessions'});assert.equal(response.ok,true);}assert.ok(f.events.some(e=>e.principalId==='agent.one'&&e.generation===1&&e.outcome==='completed'));assert.ok(f.events.some(e=>e.principalId==='agent-two'&&e.outcome==='completed'));const r=await fetch(`${f.one.address}/v1/challenge`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:authorization(f.one.token,'/v1/challenge')},body:'{}'});assert.equal(r.status,401);});
test('another principal cannot consume a signed challenge and client checks principal binding',async t=>{const f=await fixture(t);const bundle=await(await send(f.one,'/v1/challenge')).json();channel.verifyChallenge(bundle,f.one.principalBinding,f.one.edPublic,f.one.pqPublic);assert.throws(()=>channel.verifyChallenge(bundle,f.two.principalBinding,f.two.edPublic,f.two.pqPublic));const ctx=channel.prepare(bundle);try{const body=JSON.stringify({handshake:ctx.request,payload:channel.response({kind:'agent-request',action:'sessions',args:{}},bundle,ctx)});assert.equal((await send(f.two,'/v1/actions',body)).status,400);const r=await send(f.one,'/v1/actions',body);assert.equal(r.status,200);assert.equal(channel.finish(await r.json(),bundle,ctx).status,200);}finally{ctx.responseKey.fill(0);}});
test('individual revocation leaves other principal usable and old generation cannot return',async t=>{const f=await fixture(t);f.api.reconcilePrincipals([{...f.grants[0],enabled:false},f.grants[1]]);assert.equal((await send(f.one,'/v1/challenge')).status,401);assert.equal((await requestAgent(f.two,{action:'sessions'})).ok,true);const rotated={...f.grants[0],generation:2};f.api.reconcilePrincipals([rotated,f.grants[1]]);const newer=f.api.issuePrincipal(rotated.id,2,rotated.expiresAt);assert.notEqual(newer.token,f.one.token);assert.equal((await send(f.one,'/v1/challenge')).status,401);assert.equal((await send(newer,'/v1/challenge')).status,200);});
test('rights changes abort only owned operation and retain same-generation capability',async t=>{let started,release;const began=new Promise(r=>started=r),wait=new Promise(r=>release=r);const f=await fixture(t,{actions:{sessions:async(_args,{signal})=>{started();await wait;assert.equal(signal.aborted,true);return[{id:'work'}];}}});const pending=requestAgent(f.one,{action:'sessions'});await began;f.api.reconcilePrincipals([{...f.grants[0],scopes:[]},f.grants[1]]);release();assert.equal((await pending).status,403);assert.equal(f.api.issuePrincipal(f.one.principalId,1,f.grants[0].expiresAt).token,f.one.token);assert.equal((await send(f.two,'/v1/challenge')).status,200);});
test('principal expiry aborts in-flight result and removes owned capability',async t=>{let started,release;const began=new Promise(r=>started=r),wait=new Promise(r=>release=r);const f=await fixture(t,{actions:{sessions:async()=>{started();await wait;return[{id:'work'}];}}});const short={...f.grants[0],expiresAt:new Date(Date.now()+250).toISOString()};f.api.reconcilePrincipals([short,f.grants[1]]);const credential=f.api.issuePrincipal(short.id,1,short.expiresAt);const pending=requestAgent(credential,{action:'sessions'});await began;await new Promise(r=>setTimeout(r,350));release();assert.equal((await pending).status,403);assert.equal((await send(credential,'/v1/challenge')).status,401);});
import {AsyncLocalStorage} from 'node:async_hooks';
test('each protected RPC has one parent trace and timed named child stages without content',async t=>{const storage=new AsyncLocalStorage(),spans=[];let seq=0;const traceOperation=async(name,fn)=>{const parent=storage.getStore(),row={id:++seq,name,parent:parent?.id,start:performance.now()};spans.push(row);return storage.run(row,async()=>{try{return await fn();}finally{row.duration=performance.now()-row.start;}});};const f=await fixture(t,{traceOperation});assert.equal((await requestAgent(f.one,{action:'sessions'})).ok,true);const parents=spans.filter(s=>s.name==='agent.request');assert.equal(parents.length,2);assert.ok(parents.every(s=>s.parent===undefined&&s.duration>=0));const actionParent=parents.find(p=>spans.some(s=>s.parent===p.id&&s.name==='agent.dispatch'));for(const name of ['agent.decrypt','agent.authorize','agent.audit','agent.dispatch','agent.project','agent.encrypt'])assert.ok(spans.some(s=>s.parent===actionParent.id&&s.name===name&&s.duration>=0));assert.equal(JSON.stringify(spans).includes(f.one.token),false);});
test('individual release boundary reauthorization prevents a post-dispatch denial from releasing content',async t=>{let allowed=true;const f=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]}),actions:{sessions:async()=>{allowed=false;return[{id:'private-result'}];}}});const response=await requestAgent(f.one,{action:'sessions'});assert.equal(response.status,403);assert.equal(JSON.stringify(await response.json()).includes('private-result'),false);});
test('generation rollback stays revoked even when old grant is reenabled',async t=>{const f=await fixture(t);f.api.reconcilePrincipals([{...f.grants[0],generation:2},f.grants[1]]);f.api.reconcilePrincipals(f.grants);assert.equal((await send(f.one,'/v1/challenge')).status,401);assert.throws(()=>f.api.issuePrincipal(f.one.principalId,1,f.grants[0].expiresAt));assert.equal((await requestAgent(f.two,{action:'sessions'})).ok,true);});
test('forged individual authentication never attributes an unverified actor',async t=>{const f=await fixture(t);const forged={...f.one,token:f.two.token};const r=await send(forged,'/v1/challenge');assert.equal(r.status,401);assert.equal(f.events.length,1);assert.equal(f.events[0].principalId,undefined);assert.equal(f.events[0].generation,undefined);assert.equal(f.events[0].outcome,'denied');});
test('dispatch receives only the authenticated principal and generation',async t=>{let received;const f=await fixture(t,{actions:{sessions:async(_args,context)=>{received=context;return[{id:'work'}];}}});assert.equal((await requestAgent(f.two,{action:'sessions'})).ok,true);assert.equal(received.principalId,f.two.principalId);assert.equal(received.generation,f.two.generation);assert.ok(received.signal instanceof AbortSignal);});
test('revocation during asynchronous encryption never releases computed content',async t=>{let api;const f=await fixture(t,{traceOperation:async(name,fn)=>{const result=await fn();if(name==='agent.encrypt')api.reconcilePrincipals([]);return result;}});api=f.api;const response=await requestAgent(f.one,{action:'sessions'});assert.equal(response.status,403);assert.equal(JSON.stringify(await response.json()).includes('work'),false);});
test('readiness denial during encryption is rechecked before encrypted result release',async t=>{let allowed=true;const f=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]}),traceOperation:async(name,fn)=>{const result=await fn();if(name==='agent.encrypt')allowed=false;return result;}});const response=await requestAgent(f.one,{action:'sessions'});assert.equal(response.status,403);assert.equal(JSON.stringify(await response.json()).includes('work'),false);});