Files

57 lines
11 KiB
JavaScript
Raw Permalink Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const {createVaultAgentControls}=createRequire(import.meta.url)('../apps/browser/vault-agent-controls.cjs');
function fixture(){
let clock=Date.now(),locked=false,valid=true,url='https://work.example/login',scripts=0,visual=0,generation=1;
const rows=[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',origin:'https://work.example',username:'private-user',label:'private-label'},{id:'81911cdc-a390-4e73-a604-d98d05fd52a5',origin:'https://other.example',username:'other'}];
const c={ready:true,token:{},actor:{agentId:'actor-one',generation:1},sessionId:'71911cdc-a390-4e73-a604-d98d05fd52a5',foreground:true,interactive:true,policy:{expiresAt:new Date(clock+100000).toISOString(),agents:{enabled:true,scopes:['vault:status','vault:list','vault:fill'],allowedHosts:['work.example']},passwordManager:{enabled:true,allowAutofill:true}},destinationAllowed:()=>true,webContents:{getURL:()=>url,isDestroyed:()=>false,focus(){},executeJavaScriptInIsolatedWorld:async()=>{scripts++;return {completed:true};}}};
const vault={accessGeneration:()=>generation,status:()=>({exists:true,locked}),list:()=>rows.map(row=>({...row})),credential:(id,origin)=>{assert.equal(id,rows[0].id);assert.equal(origin,rows[0].origin);return {username:'private-user',password:'private-password'};}};
const events=[];
const cb={getContext:()=>c,contextCurrent:()=>valid,getVault:()=>vault,checkVisual:async()=>{visual++;return {decision:'allow'};},inspect:async()=>({decision:'allow',coverage:{complete:true}}),audit:async e=>events.push(e),now:()=>clock};
return {c,rows,cb,make:()=>createVaultAgentControls(cb),lock:()=>locked=true,rotate:()=>generation++,invalidate:()=>valid=false,expire:()=>clock+=60001,setUrl:u=>url=u,get scripts(){return scripts;},get visual(){return visual;},events};
}
test('agent vault inventory is scoped, classified and contains opaque references only',async()=>{
const f=fixture(),a=f.make(),rows=await a.list();assert.equal(rows.length,1);assert.deepEqual(Object.keys(rows[0]).sort(),['credentialRef','origin']);assert.notEqual(rows[0].credentialRef,f.rows[0].id);
assert.deepEqual(await a.status(),{exists:true,locked:false,enabled:true,allowAutofill:true});
assert.deepEqual(await a.fill({credentialRef:rows[0].credentialRef}),{completed:true});assert.equal(f.scripts,1);
await assert.rejects(a.fill({credentialRef:rows[0].credentialRef}));assert.equal(f.scripts,1);
assert.doesNotMatch(JSON.stringify(f.events),/private|password|credentialRef/);
assert.deepEqual(Object.keys(a).sort(),['clear','fill','list','prepare','status']);
});
test('locked vault and failed/incomplete classifier cannot export inventory',async()=>{
for(const mode of ['locked','block','incomplete','error','changed']){const f=fixture();if(mode==='locked')f.lock();else f.cb.inspect=async()=>{if(mode==='error')throw Error('private-password');if(mode==='changed')f.rows[0].origin='https://changed.example';return {decision:mode==='block'?'block':'allow',coverage:{complete:mode!=='incomplete'}};};await assert.rejects(f.make().list());}
});
test('vault references cannot cross identity, origin, policy, expiry, lock or foreground',async()=>{
for(const mode of ['actor','origin','policy','expire','locked','foreground','scope','token','autofill']){const f=fixture(),a=f.make(),[row]=await a.list();if(mode==='actor')f.c.actor={agentId:'other',generation:1};if(mode==='origin')f.setUrl('https://other.example/login');if(mode==='policy')f.c.policy={...f.c.policy};if(mode==='expire')f.expire();if(mode==='locked')f.lock();if(mode==='foreground')f.c.foreground=false;if(mode==='scope')f.c.policy.agents.scopes=[];if(mode==='token')f.invalidate();if(mode==='autofill')f.c.policy.passwordManager.allowAutofill=false;await assert.rejects(a.fill({credentialRef:row.credentialRef}));assert.equal(f.scripts,0,mode);}
});
test('visual and durable audit waits recheck revocation and abort before credential effect',async()=>{
for(const mode of ['visual','audit','abort']){const f=fixture(),controller=new AbortController();if(mode==='visual')f.cb.checkVisual=async()=>{f.c.foreground=false;return {decision:'allow'};};else f.cb.audit=async()=>{if(mode==='abort')controller.abort();else f.lock();};const a=f.make(),[row]=await a.list();await assert.rejects(a.fill({credentialRef:row.credentialRef},{signal:controller.signal}));assert.equal(f.scripts,0);}
});
test('native failure does not expose credentials and consumes the fill authorization',async()=>{
const f=fixture();f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>{throw Error('private-password');};const a=f.make(),[row]=await a.list();await assert.rejects(a.fill({credentialRef:row.credentialRef}),{message:'Managed vault operation refused'});await assert.rejects(a.fill({credentialRef:row.credentialRef}));
});
test('vault generation invalidates references even when the vault is unlocked again',async()=>{
const f=fixture(),a=f.make(),[row]=await a.list();f.rotate();await assert.rejects(a.fill({credentialRef:row.credentialRef}));assert.equal(f.scripts,0);
const g=fixture();g.cb.inspect=async()=>{g.rotate();return {decision:'allow'};};await assert.rejects(g.make().list());
});
test('all callback failures are sanitized at the vault boundary',async()=>{
const f=fixture();f.cb.inspect=async()=>{throw Error('private-password');};await assert.rejects(f.make().list(),{message:'Managed vault operation refused'});
f.cb.getVault=()=>{throw Error('private-master');};await assert.rejects(f.make().status(),{message:'Managed vault operation refused'});
});
test('background reference cannot be filled in another same-origin native session',async()=>{
const f=fixture();f.c.foreground=false;const other={...f.c,foreground:true,sessionId:'81911cdc-a390-4e73-a604-d98d05fd52a5',webContents:{...f.c.webContents}};
f.cb.getContext=id=>id===other.sessionId?other:f.c;let retrieved=0;const vault=f.cb.getVault();vault.credential=()=>{retrieved++;throw Error('Should not retrieve');};
const a=f.make(),[row]=await a.list({sessionId:f.c.sessionId});await assert.rejects(a.fill({sessionId:other.sessionId,credentialRef:row.credentialRef}));assert.equal(retrieved,0);assert.equal(f.visual,0);assert.equal(f.scripts,0);
});
test('direct vault arguments reject secret-bearing or arbitrary extra fields',async()=>{
const f=fixture(),a=f.make();for(const method of ['status','list','fill'])for(const args of [{masterPassword:'secret'},{password:'secret'},{foo:'bar'},[],null])await assert.rejects(a[method](args));
});
test('allow verdict with unavailable inspection marker cannot export references',async()=>{
for(const ruleId of ['uninspectable','size-limit','inspection-incomplete','unavailable','timeout']){const f=fixture();f.cb.inspect=async()=>({decision:'allow',ruleId});await assert.rejects(f.make().list());}
});
test('prepare submission has a separate default-off scope and regular fill never arms',async()=>{const f=fixture();let arms=0;f.cb.prepareSubmission=async()=>{arms++;return {prepared:true};};const a=f.make(),[row]=await a.list();await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(f.scripts,0);assert.deepEqual(await a.fill({credentialRef:row.credentialRef}),{completed:true});assert.equal(arms,0);});
test('controlled prepare arms only once after native fill and returns sanitized status',async()=>{const f=fixture();f.c.policy.agents.scopes.push('vault:prepare-submit');f.c.policy.passwordManager.controlledSubmission={enabled:true,forms:[{id:'login',actionUrl:'https://work.example/session',usernameField:'user',passwordField:'pass'}]};let armed=0,credential,after=0;f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>({completed:true,formId:'login'});f.cb.prepareSubmission=async value=>{armed++;credential=value.credential;assert.equal(value.formId,'login');assert.equal(value.credential.password,'private-password');return {prepared:true,password:'must-not-export',formId:'private'};};f.cb.afterFill=()=>after++;const a=f.make(),[row]=await a.list();assert.deepEqual(await a.prepare({credentialRef:row.credentialRef}),{completed:true,prepared:true});assert.equal(armed,1);assert.equal(after,1);assert.equal(credential.password,'');assert.equal(credential.username,'');await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(armed,1);assert.doesNotMatch(JSON.stringify(f.events),/private-password|credentialRef/);});
test('revocation or failed trusted handoff stops native credential write and consumes reference',async()=>{for(const mode of ['revoked','error','aborted']){const f=fixture(),controller=new AbortController();f.cb.beforeFill=async()=>{if(mode==='revoked')f.invalidate();if(mode==='error')throw Error('private handoff error');if(mode==='aborted')controller.abort();};const a=f.make(),[row]=await a.list();await assert.rejects(a.fill({credentialRef:row.credentialRef},{signal:controller.signal}),{message:'Managed vault operation refused'});assert.equal(f.scripts,0);await assert.rejects(a.fill({credentialRef:row.credentialRef}));}});
test('controlled policy disabled and native renderer form mismatch never arm broker',async()=>{for(const mode of ['disabled','native-fail']){const f=fixture();f.c.policy.agents.scopes.push('vault:prepare-submit');f.c.policy.passwordManager.controlledSubmission={enabled:mode!=='disabled',forms:[{id:'login',actionUrl:'https://work.example/session',usernameField:'user',passwordField:'pass'}]};let armed=0;f.cb.prepareSubmission=async()=>{armed++;return {prepared:true};};f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>{throw Error('Form changed');};const a=f.make(),[row]=await a.list();await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(armed,0);}});
test('post-arm revocation and handoff completion failure cancel only the returned private lease',async()=>{for(const mode of ['revoked','after-fill-error']){const f=fixture();f.c.policy.agents.scopes.push('vault:prepare-submit');f.c.policy.passwordManager.controlledSubmission={enabled:true,forms:[{id:'login',actionUrl:'https://work.example/session',usernameField:'user',passwordField:'pass'}]};let cancelled=0,armed=0;f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>({completed:true,formId:'login'});f.cb.prepareSubmission=async()=>{armed++;if(mode==='revoked')f.invalidate();return {prepared:true,cancel:()=>cancelled++};};if(mode==='after-fill-error')f.cb.afterFill=()=>{throw Error('Private UI error');};const a=f.make(),[row]=await a.list();await assert.rejects(a.prepare({credentialRef:row.credentialRef}),{message:'Managed vault operation refused'});assert.equal(armed,1);assert.equal(cancelled,1);await assert.rejects(a.prepare({credentialRef:row.credentialRef}));assert.equal(armed,1);}});