Files
safetynet/tests/human-interaction.test.mjs
T

83 lines
7.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {createRequire} from 'node:module';
const {humanInteractionScript}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs');
const token='a'.repeat(32),url='https://intranet.example/path?private=1';
function fixture(){
let time=1000;const listeners=new Map();const win={addEventListener:(type,fn)=>listeners.set(type,fn),removeEventListener:(type,fn)=>{if(listeners.get(type)===fn)listeners.delete(type);}};win.top=win;
const context=vm.createContext({window:win,location:{href:url},innerWidth:1000,innerHeight:800,navigator:{userActivation:{isActive:true}},Date:{now:()=>time}});
const run=mode=>vm.runInContext(humanInteractionScript(mode,{url,token}),context);
const target=(tag='BUTTON',attrs={})=>({nodeType:1,tagName:tag,isConnected:true,parentElement:null,type:'button',hasAttribute:key=>Object.hasOwn(attrs,key),getAttribute:key=>attrs[key]||null,closest:()=>null});
const emit=(type,element=target(),extra={})=>listeners.get(type)?.({type,target:element,isTrusted:true,clientX:12.2,clientY:31.8,...extra});
return {run,target,emit,listeners,context,advance:ms=>{time+=ms;}};
}
test('trusted observation exports structural metadata only, rejects synthetic events',()=>{
const f=fixture();assert.equal(f.run('install').installed,true);
f.emit('click',f.target('BUTTON',{'aria-label':'Confidential deal','data-value':'sensitive'}),{isTrusted:false});
assert.equal(f.run('drain').events.length,0);
f.emit('click',f.target('BUTTON',{'aria-label':'ordinary button','id':'id-private','data-value':'sensitive'}));
const result=JSON.parse(JSON.stringify(f.run('drain')));
assert.deepEqual(result.events,[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON',x:12,y:32}}]);
assert.equal(JSON.stringify(result).includes('id-private'),false);
});
test('password, credential ancestors and sensitive forms never contribute metadata',()=>{
const f=fixture();f.run('install');const password=f.target('INPUT');password.type='password';
f.emit('click',password);const child=f.target();child.parentElement=f.target('DIV',{'data-sensitive':''});f.emit('click',child);
const form=f.target('FORM');form.elements=[password];f.emit('submit',form);const button=f.target();button.form=form;f.emit('click',button);
assert.equal(f.run('drain').events.length,0);
});
test('submit is observed, not evidence of transmission, and carries no fields',()=>{
const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[f.target('INPUT')];form.elements[0].value='company confidential';
f.emit('keydown',form.elements[0],{key:'Enter'});
f.emit('submit',form,{defaultPrevented:true});
assert.deepEqual(JSON.parse(JSON.stringify(f.run('drain').events)),[{action:'submit',outcome:'observed',sensitiveField:false,target:{tag:'OTHER'}}]);
});
test('queue and rate are bounded, lease expiry and navigation prevent new capture',()=>{
const f=fixture();f.run('install');for(let i=0;i<1000;i++)f.emit('click');
let result=f.run('drain');assert.equal(result.events.length,16);assert.equal(result.dropped,984);
for(let i=0;i<100;i++)f.emit('click');assert.equal(f.run('drain').events.length,4);
f.advance(1600);f.emit('click');assert.equal(f.run('drain').events.length,0);
f.context.location.href='https://other.example/';f.emit('click');assert.equal(f.run('drain'),null);
});
test('replacement and dispose remove listeners and pending metadata',()=>{
const f=fixture();f.run('install');f.emit('click');f.run('install');assert.equal(f.run('drain').events.length,0);
f.run('dispose');assert.equal(f.listeners.size,0);assert.equal(f.run('drain'),null);
assert.throws(()=>humanInteractionScript('install',{url,token:'untrusted'}));
});
const {createHumanInteractionObserver}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs');
function controllerFixture(){
const dom=fixture();const contents={isDestroyed:()=>false};let context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:contents};const seen=[];
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:s=>context.ready&&context.enabled&&s.token===context.token&&s.policyVersion===context.policyVersion&&s.foreground===context.foreground,executeScript:async(_contents,code)=>vm.runInContext(code,dom.context),onObserved:async event=>seen.push(event)});
return {dom,observer,seen,set:update=>{context={...context,...update};}};
}
test('controller binds current navigation and policy and drops queued stale metadata',async()=>{
const f=controllerFixture();try{
await f.observer.poll();f.dom.emit('click');await f.observer.poll();assert.equal(f.seen.length,1);
f.dom.emit('click');f.set({token:'nav-2'});await f.observer.poll();await f.observer.poll();assert.equal(f.seen.length,1);
f.dom.emit('click');f.set({enabled:false});await f.observer.poll();assert.equal(f.dom.listeners.size,0);assert.equal(f.seen.length,1);
}finally{f.observer.dispose();}
});
test('refresh and dispose invalidate in-flight batches without replay',async()=>{
let resolve;const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};const seen=[];let calls=0;
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};return new Promise(r=>{resolve=r;});},onObserved:async value=>seen.push(value)});
try{await observer.poll();const pending=observer.poll();observer.refresh();resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();}
});
test('submit without native user activation is excluded',()=>{
const f=fixture();f.run('install');f.context.navigator.userActivation.isActive=false;const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
});
test('late DOM drain remains serialized and expires without invoking audit',async()=>{
const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let resolve,calls=0;const seen=[];
const observer=createHumanInteractionObserver({deadline:100,getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};if(calls===2)return new Promise(r=>{resolve=r;});return null;},onObserved:async event=>seen.push(event)});
try{await observer.poll();const pending=observer.poll();await new Promise(r=>setTimeout(r,120));await observer.poll();assert.equal(calls,2);resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();}
});
test('destroyed renderer disposal errors never escape lifecycle callback',async()=>{
const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let installed=false;
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:()=>{if(!installed){installed=true;return {installed:true};}throw Error('Destroyed renderer');},onObserved:()=>{}});
await observer.poll();assert.doesNotThrow(()=>observer.dispose());await new Promise(r=>setImmediate(r));
});
test('trusted programmatic submit without recent click or Enter is excluded',()=>{
const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
f.emit('keydown',form,{key:'Enter'});f.advance(1100);f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
});