96 lines
7.4 KiB
JavaScript
96 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);
|
||
|
|
});
|