Files
safetynet/tests/agent-control.test.mjs
T
2026-10-13 10:34:48 +02:00

97 lines
7.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const { startAgentControl } = createRequire(import.meta.url)('../apps/browser/agent-control.cjs');
async function fixture(t, overrides = {}) {
const calls = [], events = [];
const api = await startAgentControl({ authorize: async ({scope}) => ({allowed:true, role:'agent',scopes:[scope]}), actions: {sessions:async()=>[{id:'work',title:'Work',cookies:'secret'}], navigate:async args=>{calls.push(args);return{};}, read:async()=>({text:'sanitized',password:'secret'}),type:async()=>({}),click:async()=>({}),ai:async()=>({text:'answer'})},audit:async event=>events.push(event),...overrides });
t.after(()=>api.close());
const request = (action,args={},headers={})=>fetch(`${api.address}/v1/actions`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${api.token}`,...headers},body:JSON.stringify({action,args})});
return {api, request,calls,events};
}
test('loopback capability authenticates and strips private adapter fields',async t=>{
const {request,calls,events}=await fixture(t);
assert.equal((await request('sessions',{}, {Authorization:'Bearer wrong'})).status,401);
assert.deepEqual(await (await request('sessions')).json(),{result:[{id:'work',title:'Work'}]});
assert.deepEqual(await (await request('read',{sessionId:'work'})).json(),{result:{text:'sanitized'}});
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,200);
assert.equal(calls.length,1); assert.ok(events.some(e=>e.outcome==='authorized'));
});
test('omitted session remains omitted and native inventory exposes only approved foreground metadata',async t=>{
let captured;
const {request}=await fixture(t,{actions:{sessions:async()=>[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true,cookies:'secret'}],read:async args=>{captured=args;return{text:'safe'};}}});
assert.equal((await request('read')).status,200);
assert.equal(Object.hasOwn(captured,'sessionId'),false);
assert.deepEqual((await (await request('sessions')).json()).result,[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true}]);
});
test('typed app actions reject arbitrary settings and project only public application metadata',async t=>{
const calls=[];
const {request}=await fixture(t,{actions:{'application:state':async()=>({ready:true,policyVersion:2,activeTabId:'tab',tabCount:2,token:'credential',server:'private'}),'settings:update':async args=>{calls.push(args);return{visible:true,appearance:args.appearance};},'tabs:create':async args=>{calls.push(args);return{completed:true,sessionId:'new-tab',token:'credential'};}}});
assert.deepEqual((await(await request('application:state')).json()).result,{ready:true,policyVersion:2,activeTabId:'tab',tabCount:2});
assert.equal((await request('settings:update',{appearance:{allowDevTools:true}})).status,400);
assert.equal((await request('settings:update',{appearance:{mode:'dark'}})).status,200);
assert.equal((await request('tabs:create',{url:'file:///C:/secret'})).status,400);
assert.equal((await request('tabs:create',{url:'https://user:[email protected]/'})).status,400);
assert.equal((await request('tabs:activate',{})).status,400);
assert.deepEqual((await(await request('tabs:create',{group:'Work',url:'https://example.test/'})).json()).result,{completed:true,sessionId:'new-tab'});
assert.equal(calls.length,2);
});
test('bookmark mutation schema requires exact revision and rejects extra capability arguments',async t=>{
let captured;
const {request}=await fixture(t,{actions:{'bookmarks:mutate':async args=>{captured=args;return{completed:true};}}});
const args={action:'add',expectedRevision:0,policyVersion:1,bookmark:{id:'personal',title:'Intranet',url:'https://intranet.example/',folder:'Work'}};
assert.equal((await request('bookmarks:mutate',args)).status,200);
assert.equal((await request('bookmarks:mutate',{...args,expectedRevision:-1})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,approve:true})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...args.bookmark,managed:true}})).status,400);
const {folder,...withoutFolder}=args.bookmark;
assert.equal((await request('bookmarks:mutate',{...args,bookmark:withoutFolder})).status,200);
assert.equal(captured.bookmark.folder,'');
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:123}})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:null}})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:'Work\nSecrets'}})).status,400);
});
test('scope and role are centrally authorized on every action',async t=>{
const {request,calls}=await fixture(t,{authorize:async()=>({allowed:true,role:'agent',scopes:['sessions:list']})});
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,403);
assert.equal(calls.length,0);
});
test('rejects browser origins, execution and unsafe navigation',async t=>{
const {request}=await fixture(t);
assert.equal((await request('sessions',{}, {Origin:'https://evil.example'})).status,403);
assert.equal((await request('evaluate',{code:'process.exit()'})).status,400);
assert.equal((await request('navigate',{sessionId:'work',url:'file:///C:/secret'})).status,400);
assert.equal((await request('navigate',{sessionId:'work',url:'https://user:[email protected]'})).status,400);
assert.equal((await request('read',{sessionId:'work',cookies:true})).status,400);
});
test('revocation immediately disables capability and policy denial changes live',async t=>{
let allowed=true;
const {api,request}=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]})});
assert.equal((await request('sessions')).status,200); allowed=false;
assert.equal((await request('sessions')).status,403); api.revoke();
assert.equal((await request('sessions')).status,401);
});
test('timeouts abort adapter and rate limit prevents repeated actions',async t=>{
let signal;
const {request}=await fixture(t,{timeoutMs:20,maxRequestsPerMinute:1,actions:{read:async(_,context)=>{signal=context.signal;await new Promise(resolve=>setTimeout(resolve,100));return{text:'late'};}}});
assert.equal((await request('read',{sessionId:'work'})).status,504);assert.equal(signal.aborted,true);
assert.equal((await request('read',{sessionId:'work'})).status,429);
});
test('audit outage fails closed before adapter execution',async t=>{
const {request,calls}=await fixture(t,{audit:async()=>{throw Error('offline');}});
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,500);
assert.equal(calls.length,0);
});
test('trusted diagnostics report known static reasons and suppress arbitrary sensitive messages',async t=>{
const diagnostics=[];
const {request}=await fixture(t,{onError:async value=>diagnostics.push(value),actions:{read:async()=>{throw Error('Company policy blocks content access');},type:async()=>{throw Error('password=do-not-log-this');}}});
assert.equal((await request('read')).status,500);
assert.equal((await request('type',{selector:'#field',text:'public'})).status,500);
assert.equal(diagnostics[0].reason,'Company policy blocks content access');
assert.equal(diagnostics[1].reason,'unclassified-action-failure');
assert.equal(JSON.stringify(diagnostics).includes('do-not-log-this'),false);
});