Files
safetynet/tests/agent-history.test.mjs

47 lines
5.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const {createAgentAdapters}=createRequire(import.meta.url)('../apps/browser/agent-adapters.cjs');
function fixture(){
let url='https://work.example/current',generation=0,history={activeIndex:1,entries:[{url:'https://work.example/old',title:'Old'},{url,title:'Current'}]},calls=0;
const policy={version:1,agents:{enabled:true,scopes:['history:list','history:go'],allowedHosts:['work.example'],allowSensitiveRead:true}};
const webContents={getURL:()=>url},context=()=>({ready:true,policy,sessionId:'session',foreground:true,token:generation+1,webContents,destinationAllowed:()=>true});
const callbacks={getContext:context,getSessions:()=>[],contextCurrent:token=>token===generation+1,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{},getHistory:()=>history,goHistory:async(index,initial,{historyBinding})=>{assert.equal(JSON.stringify(history),historyBinding);calls++;url=history.entries[index].url;generation++;return {completed:true,context:context()};}};
return {callbacks,policy,history,actions:()=>createAgentAdapters(callbacks),calls:()=>calls,invalidate:()=>generation++};
}
test('history exports bounded metadata and opaque identifiers, navigates exact snapshot once',async()=>{
const f=fixture(),a=f.actions(),list=await a['history:list']({});assert.equal(list.entries.length,2);assert.equal(list.entries[0].index,undefined);assert.match(list.snapshotId,/^[a-f0-9-]{36}$/);
assert.deepEqual(await a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}),{completed:true});assert.equal(f.calls(),1);
await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),1);
});
test('history masks credentials and DLP without sensitive override, discards native page state',async()=>{
const f=fixture();f.history.entries[0]={url:'https://work.example/?access_token=private',title:'password=secret22',pageState:'private form'};f.callbacks.inspect=async()=>({decision:'block',ruleId:'corporate'});
const list=await f.actions()['history:list']({});assert.equal(list.entries[0].title,'Protected page');assert.equal(list.entries[0].url,undefined);assert.equal(list.entries[1].url,undefined);assert.ok(!JSON.stringify(list).includes('private'));
});
test('history inspection errors, incomplete coverage and asynchronous replacement fail closed',async()=>{
for(const verdict of [null,{decision:'allow',coverage:{complete:false}},{decision:'block',ruleId:'uninspectable'}]){const f=fixture();f.callbacks.inspect=async()=>verdict;await assert.rejects(f.actions()['history:list']({}),/Inspection/);}
for(const mutate of [f=>f.history.entries[0].title='Changed',f=>f.invalidate(),f=>f.policy.agents.scopes=[]]){const f=fixture();f.callbacks.inspect=async()=>{mutate(f);return{decision:'allow'};};await assert.rejects(f.actions()['history:list']({}));}
});
test('history index replacement, authority revocation and foreign identifiers prevent effects',async()=>{
for(const mutate of [f=>f.history.entries[0].url='https://work.example/replaced',f=>f.invalidate(),f=>f.policy.agents.scopes=['history:list']]){const f=fixture(),a=f.actions(),list=await a['history:list']({});mutate(f);await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);}
const f=fixture(),a=f.actions(),list=await a['history:list']({});await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:'foreign'}));assert.equal(f.calls(),0);
});
test('history host restrictions exclude entries and cancellation prevents disclosure',async()=>{
const f=fixture();f.history.entries[0].url='https://other.example/';assert.equal((await f.actions()['history:list']({})).entries.length,1);
const g=fixture(),controller=new AbortController();g.callbacks.inspect=async()=>{controller.abort();return{decision:'allow'};};await assert.rejects(g.actions()['history:list']({}, {signal:controller.signal}));
});
test('history grants cannot cross agent identities or navigate masked credentials',async()=>{
const f=fixture();let actor={agentId:'a',generation:1};const context=f.callbacks.getContext;f.callbacks.getContext=()=>({...context(),actor});
const a=f.actions(),list=await a['history:list']({});actor={agentId:'b',generation:1};await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
const g=fixture();g.history.entries[0].url='https://work.example/?code=private';const b=g.actions(),masked=await b['history:list']({});await assert.rejects(b['history:go']({snapshotId:masked.snapshotId,entryId:masked.entries[0].entryId}));assert.equal(g.calls(),0);
});
test('encoded query keys and fragment credentials are masked and cannot be navigated',async()=>{
for(const suffix of ['?%74oken=private','?%2574oken=private','#refresh_token=private','?sid=private','?client_secret=private']){
const f=fixture();f.history.entries[0].url='https://work.example/'+suffix;const a=f.actions(),list=await a['history:list']({});assert.equal(list.entries[0].url,undefined);assert.equal(list.entries[0].title,'Protected page');await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
}
});
test('native page-state checksum stays private and detects same-title/URL replacement',async()=>{
const f=fixture();f.history.nativeBinding='a'.repeat(64);const a=f.actions(),list=await a['history:list']({});assert.ok(!JSON.stringify(list).includes('a'.repeat(64)));f.history.nativeBinding='b'.repeat(64);await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
const g=fixture();g.history.nativeBinding='a'.repeat(64);g.callbacks.inspect=async()=>{g.history.nativeBinding='b'.repeat(64);return{decision:'allow'};};await assert.rejects(g.actions()['history:list']({}));
});