Files
safetynet/tests/policy-history.test.mjs
2026-10-13 10:34:48 +02:00

59 lines
7.3 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {openStore,digest} from '../apps/server/store.mjs';
import {createApp} from '../apps/server/app.mjs';
import {verifyPolicy} from '../packages/core/policy.mjs';
import {policyDiff} from '../apps/server/policy-history.mjs';
async function fixture(t){
const dir=mkdtempSync(join(tmpdir(),'safetynet-history-')),store=openStore(dir),credential='synthetic-history-admin-credential-123456789',origin='http://127.0.0.1:4310';
const server=createApp(store,{adminToken:credential,publicUrl:origin,secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});
async function api(path,body,role){
const headers={'content-type':'application/json'};
if(role){const key=`synthetic-history-${role}-credential-123456789`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(key),Date.now()+60000,JSON.stringify({sub:role,role}));headers.cookie=`sn_session=${key}`;headers.origin=origin;}else headers.authorization=`Bearer ${credential}`;
const response=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined});return {status:response.status,body:await response.json()};
}
function save(policy){store.put('policy',policy);store.db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(policy.version,new Date().toISOString(),'test-publisher',JSON.stringify(policy));}
return {store,api,save};
}
test('history cursor, schema snapshots and deterministic diffs cover immutable global versions',async t=>{
const f=await fixture(t),p=f.store.get('policy');p.version=2;p.brand.name='Company Browser';f.save(p);
const list=await f.api('/api/admin/policy/history?limit=1');assert.equal(list.status,200);assert.equal(list.body.versions[0].version,2);assert.equal(list.body.versions[0].current,true);assert.equal(list.body.nextBefore,2);
const next=await f.api('/api/admin/policy/history?limit=1&before=2');assert.equal(next.body.versions[0].version,1);assert.equal(next.body.nextBefore,null);
const item=await f.api('/api/admin/policy/history/2');assert.equal(item.body.policy.brand.name,'Company Browser');assert.equal(JSON.stringify(item.body).includes('privateKey'),false);
const diff=await f.api('/api/admin/policy/diff?from=1&to=2');assert.equal(diff.status,200);assert.ok(diff.body.changes.some(c=>c.path==='/brand/name'&&c.after==='Company Browser'));assert.equal(diff.body.truncated,false);
assert.equal((await f.api('/api/admin/policy/history/999')).status,404);assert.equal((await f.api('/api/admin/policy/diff?from=1&to=999')).status,404);assert.equal((await f.api('/api/admin/policy/history?limit=101')).status,400);
});
test('restore publishes new signed monotonic policy with refreshed expiry and preserves source and audit privacy',async t=>{
const f=await fixture(t),expired=f.store.get('policy');expired.expiresAt='2020-01-01T00:00:00.000Z';f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(expired));
const source=f.store.db.prepare('SELECT policy FROM policies WHERE version=1').get().policy,p=f.store.get('policy');p.version=2;p.brand.name='Changed';f.save(p);
const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(result.status,200);assert.equal(result.body.version,3);assert.equal(result.body.sourceVersion,1);
const signed=verifyPolicy(f.store.envelope(),f.store.publicKey());assert.equal(signed.version,3);assert.notEqual(signed.brand.name,'Changed');assert.ok(Date.parse(signed.expiresAt)>Date.now()+6*86400000);
assert.equal(f.store.db.prepare('SELECT policy FROM policies WHERE version=1').get().policy,source);
const audit=JSON.parse(f.store.db.prepare("SELECT metadata FROM events WHERE kind='policy.restored'").get().metadata);assert.deepEqual(audit,{actor:'bootstrap-admin',version:3,sourceVersion:1});
assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true})).status,409);assert.equal(f.store.get('policy').version,3);
assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:999,expectedVersion:3,approve:true})).status,404);assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:3,approve:false})).status,400);
});
test('invalid historic schemas cannot be restored and failure rolls back without new audit or version',async t=>{
const f=await fixture(t),invalid=f.store.get('policy');invalid.proxy={mode:'unknown-mode'};f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(invalid));
const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:1,approve:true});assert.equal(result.status,400);assert.equal(f.store.get('policy').version,1);assert.equal(f.store.db.prepare("SELECT COUNT(*) AS count FROM events WHERE kind='policy.restored'").get().count,0);
assert.equal((await f.api('/api/admin/policy/history')).status,200);
});
test('history and restoration enforce read/write roles and never expose legacy personal bookmarks',async t=>{
const f=await fixture(t),p=f.store.get('policy');p.browser.bookmarks.personal={revision:2,items:[{id:'private',title:'Private bookmark',url:'https://private.example/',folder:''}]};f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(p));
for(const role of ['operator','auditor']){assert.equal((await f.api('/api/admin/policy/history',null,role)).status,200);const item=await f.api('/api/admin/policy/history/1',null,role);assert.deepEqual(item.body.policy.browser.bookmarks.personal,{revision:0,items:[]});assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:1,approve:true},role)).status,403);}
assert.equal(f.store.get('policy').version,1);
});
test('restore cannot collide historical company IDs with current device personal entries',async t=>{
const f=await fixture(t),source=f.store.get('policy');source.browser.bookmarks.managed=[{id:'collision',title:'Company',url:'https://company.example/',folder:''}];f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(source));
const p=f.store.get('policy');p.version=2;f.save(p);f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('test-device','Test','win32','user','hash');f.store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?)').run('test-device',1,JSON.stringify([{id:'collision',title:'Private',url:'https://private.example/',folder:''}]));
const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(result.status,409);assert.equal(f.store.get('policy').version,2);assert.equal(f.store.db.prepare('SELECT COUNT(*) AS count FROM policies').get().count,2);
});
test('diff limits size, preserves arrays and escapes JSON pointer paths',()=>{
const result=policyDiff({'a/b':1,'x~y':1,list:[1]}, {'a/b':2,'x~y':2,list:[2]},2);assert.equal(result.truncated,true);assert.deepEqual(result.changes.map(c=>c.path),['/a~1b','/list']);assert.deepEqual(result.changes[1].after,[2]);
assert.deepEqual(policyDiff({}, {new:1}).changes,[{path:'/new',kind:'added',after:1}]);
});