Files
safetynet/tests/agent-session-adapters.test.mjs

15 lines
6.9 KiB
JavaScript

import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url);const {createAgentSessionAdapters,safeSessionTab}=require('../apps/browser/agent-session-adapters.cjs');
function fixture(){let revision='r1',saved=null,decision={decision:'allow',coverage:{complete:true}},restores=0;const policy={version:1,agents:{enabled:true,scopes:['sessions:save','sessions:list','sessions:restore'],allowedHosts:['example.com']}};const c={ready:true,policy,sessionId:'tab1',token:'token',actor:{agentId:'agent1',generation:1},webContents:{getURL:()=> 'https://example.com/docs'},destinationAllowed:()=>true};const store={list:()=>({revision,collections:saved?[saved]:[]}),save:(value,opt)=>{assert.equal(opt.expectedRevision,revision);saved={id:'id1',...value};revision='r2';return {id:saved.id,revision};},load:()=>({revision,...saved})};const adapters=createAgentSessionAdapters({getContext:()=>c,getSessions:()=>[{id:'tab1',title:'Public docs'}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device1',ownerId:c.actor.agentId}),store,inspect:async()=>decision,restore:async(tabs,opt)=>{opt.assertCurrent();restores++;return {completed:true,tabCount:tabs.length};}});return {adapters,c,store,setDecision:v=>decision=v,setRevision:v=>revision=v,restores:()=>restores};}
test('session collections classify before storage, restore requires one-use plan',async()=>{const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});assert.equal((await f.adapters['sessions:list']()).collections.length,1);const plan=await f.adapters['sessions:restore']({id:'id1'});assert.equal(f.restores(),0);assert.equal((await f.adapters['sessions:restore']({planId:plan.planId})).completed,true);await assert.rejects(f.adapters['sessions:restore']({planId:plan.planId}));assert.equal(f.restores(),1);});
test('incomplete and blocked classification fails closed before store/export',async()=>{for(const verdict of [{decision:'block'},{decision:'allow',coverage:{complete:false}},{decision:'allow',ruleId:'timeout'}]){const f=fixture();f.setDecision(verdict);await assert.rejects(f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(f.store.list().collections.length,0);}});
test('changed generation and changed storage invalidate restore plans',async()=>{for(const mutate of [f=>f.c.actor.generation++,f=>f.setRevision('r3'),f=>f.c.policy={...f.c.policy}]){const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await f.adapters['sessions:restore']({id:'id1'});mutate(f);await assert.rejects(f.adapters['sessions:restore']({planId:p.planId}));assert.equal(f.restores(),0);}});
test('session metadata rejects auth pages, URL secrets and arbitrary state',()=>{for(const url of ['https://example.com/login','https://example.com/oauth/callback','https://example.com/docs?token=x','https://example.com/#code=secret','file:///tmp/a','https://user:[email protected]/','https://example.com/%70assword/reset'])assert.throws(()=>safeSessionTab({url,title:'Public'}));assert.throws(()=>safeSessionTab({url:'https://example.com/',title:'password=secret'}));});
test('session scopes default off and cancellation denies operations',async()=>{const f=fixture();f.c.policy.agents.scopes=[];await assert.rejects(f.adapters['sessions:list']());f.c.policy.agents.scopes=['sessions:list'];await assert.rejects(f.adapters['sessions:list']({}, {signal:AbortSignal.abort()}));});
test('changed native target while DLP awaits prevents snapshot persistence',async()=>{const f=fixture();let moved=false;f.c.webContents.getURL=()=>moved?'https://example.com/changed':'https://example.com/docs';const original=f.store.list;f.store.list=()=>{moved=true;return original();};await assert.rejects(f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(original().collections.length,0);});
test('changing stored revision during final restore inspection prevents release',async()=>{const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await f.adapters['sessions:restore']({id:'id1'});const load=f.store.load;f.store.load=()=>{const value=load();f.setRevision('r3');return value;};await assert.rejects(f.adapters['sessions:restore']({planId:p.planId}));assert.equal(f.restores(),0);});
test('metadata never persists page state or credential-shaped collection names',async()=>{const f=fixture();await assert.rejects(f.adapters['sessions:save']({name:'access_token=abcd',sessionIds:['tab1']}));assert.throws(()=>safeSessionTab({url:'https://example.com/',title:'Public',pageState:'credential'}));});
test('benign app search and fragments retain full target while encoded credential parameters fail',()=>{const url='https://example.com/docs?project=public&page=2#chapter-3';assert.equal(safeSessionTab({url,title:'Docs'}).url,url);for(const bad of ['https://example.com/docs?%2574oken=x','https://example.com/docs#sid=abcd','https://example.com/%256cogin'])assert.throws(()=>safeSessionTab({url:bad,title:'Docs'}));});
test('session save preserves groups and detects asynchronous group changes',async()=>{const f=fixture();let group='Research';f.c.webContents.getURL=()=> 'https://example.com/docs';const original=f.store.list;f.store.list=()=>{group='Changed';return original();};const {createAgentSessionAdapters}=require('../apps/browser/agent-session-adapters.cjs');const adapter=createAgentSessionAdapters({getContext:()=>f.c,getSessions:()=>[{id:'tab1',title:'Public docs',group}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device1',ownerId:'agent1'}),store:f.store,inspect:async()=>({decision:'allow'}),restore:async()=>({completed:true})});await assert.rejects(adapter['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(original().collections.length,0);f.store.list=original;await adapter['sessions:save']({name:'Work',sessionIds:['tab1']});assert.equal(original().collections[0].tabs[0].group,'Changed');});
test('native restore can deliberately change activation but source navigation remains bound',async()=>{for(const changeNavigation of [false,true]){const f=fixture();let activation=1,navigation=4;f.c.token={foreground:true,activation:1,navigationId:4,url:'https://example.com/docs',tabId:'tab1'};const adapter=createAgentSessionAdapters({getContext:()=>f.c,getSessions:()=>[{id:'tab1',title:'Public docs'}],contextCurrent:t=>t.navigationId===navigation&&(!t.foreground||t.activation===activation),getOwner:()=>({deviceId:'device1',ownerId:'agent1'}),store:f.store,inspect:async()=>({decision:'allow'}),restore:async(_tabs,{assertCurrent})=>{activation++;if(changeNavigation)navigation++;assertCurrent();return {completed:true};}});await adapter['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await adapter['sessions:restore']({id:'id1'});if(changeNavigation)await assert.rejects(adapter['sessions:restore']({planId:p.planId}));else assert.equal((await adapter['sessions:restore']({planId:p.planId})).completed,true);}});