Files
safetynet/tests/session-snapshot.test.mjs
T

17 lines
5.9 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import crypto from 'node:crypto';
import snapshot from '../apps/browser/session-snapshot.cjs';
const {sanitizeSessionCollection,createSessionSnapshotStore}=snapshot;
const sample=()=>({name:'Research',tabs:[{url:'https://example.test/docs',title:'Documentation',group:'Research'}],activeIndex:0});
function withStore(fn){const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-session-store-')),filePath=path.join(dir,'sessions.bin'),key=crypto.randomBytes(32);const options={filePath,protect:plain=>{const nonce=crypto.randomBytes(12),cipher=crypto.createCipheriv('aes-256-gcm',key,nonce),body=Buffer.concat([cipher.update(plain),cipher.final()]);return Buffer.concat([nonce,cipher.getAuthTag(),body]);},unprotect:bytes=>{const decipher=crypto.createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));decipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]);}};try{fn({store:createSessionSnapshotStore(options),options,filePath,dir});}finally{key.fill(0);fs.rmSync(dir,{recursive:true,force:true});}}
const owner={deviceId:crypto.randomUUID(),ownerId:'agent:research'};
test('session collection preserves usable native target but excludes serialized page state',()=>{assert.deepEqual(sanitizeSessionCollection(sample()),sample());for(const extra of ['pageState','formValues','history','password']){const value=sample();value.tabs[0][extra]='never persist';assert.throws(()=>sanitizeSessionCollection(value));}});
test('session collection rejects auth targets and encoded known secrets rather than changing semantics',()=>{for(const url of ['https://example.test/?token=x','https://example.test/#code=x','https://example.test/callback','https://user:[email protected]/','file:///C:/secret','https://example.test/password%253Dsensitive','https://example.test/sk-123456789012345678901']){const value=sample();value.tabs[0].url=url;assert.throws(()=>sanitizeSessionCollection(value));}const value=sample();value.tabs[0].title='api_key=highlysensitive';assert.throws(()=>sanitizeSessionCollection(value));});
test('session store ciphertext survives restart, enforces owner and CAS, preserves multiple collections',()=>withStore(({store,options,filePath})=>{let state=store.list(owner);const first=store.save(sample(),{...owner,expectedRevision:state.revision});assert.equal(fs.readFileSync(filePath).includes(Buffer.from('Documentation')),false);assert.throws(()=>store.save(sample(),{...owner,expectedRevision:state.revision}));state=store.list(owner);const second=store.save({...sample(),name:'Another'},{...owner,expectedRevision:state.revision});assert.equal(store.list(owner).collections.length,2);const restarted=createSessionSnapshotStore(options);assert.equal(restarted.load({...owner,id:first.id}).tabs[0].url,'https://example.test/docs');assert.equal(restarted.list({...owner,ownerId:'other'}).collections.length,0);assert.throws(()=>restarted.load({...owner,ownerId:'other',id:first.id}));assert.throws(()=>restarted.save({...sample(),id:first.id},{...owner,ownerId:'other',expectedRevision:second.revision}));restarted.remove({...owner,id:first.id,expectedRevision:second.revision});assert.equal(restarted.list(owner).collections.length,1);}));
test('session store rejects ciphertext alteration and unsafe files',()=>withStore(({store,filePath,dir})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const bytes=fs.readFileSync(filePath);bytes[bytes.length-1]^=1;fs.writeFileSync(filePath,bytes);assert.throws(()=>store.list(owner));fs.unlinkSync(filePath);fs.mkdirSync(filePath);assert.throws(()=>store.list(owner));fs.rmdirSync(filePath);fs.writeFileSync(filePath,'synthetic');fs.linkSync(filePath,path.join(dir,'alias'));assert.throws(()=>store.list(owner));}));
test('session bounds reject oversized and structurally invalid collections',()=>{for(const change of [s=>s.tabs=Array.from({length:33},()=>s.tabs[0]),s=>s.activeIndex=1,s=>s.name='x'.repeat(101),s=>s.tabs[0].title='x'.repeat(201),s=>s.tabs[0].group='x'.repeat(101)]){const value=sample();change(value);assert.throws(()=>sanitizeSessionCollection(value));}});
test('benign query and fragment destinations retain exact navigation semantics',()=>{const value=sample();value.tabs[0].url='https://example.test/search?q=research&page=2#results';assert.equal(sanitizeSessionCollection(value).tabs[0].url,value.tabs[0].url);});
test('persistence checks authority after protection before committing ciphertext',()=>withStore(({store,options,filePath})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const before=fs.readFileSync(filePath);let permitted=true;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);permitted=false;return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Forbidden after change'},{...owner,expectedRevision:store.list(owner).revision,assertCurrent:()=>{if(!permitted)throw Error('Authority changed');}}));assert.deepEqual(fs.readFileSync(filePath),before);assert.equal(store.list(owner).collections.length,1);}));
test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options,filePath})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);}));