Files
safetynet/tests/user-profiles.test.mjs
T

16 lines
6.6 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
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 {UserProfileSchema,applyUserProfiles} from '../packages/core/user-profiles.mjs';
import {defaultPolicy,destinationAllowed,verifyPolicy,signPolicy} from '../packages/core/policy.mjs';
async function fixture(t,review=false){const dir=mkdtempSync(join(tmpdir(),'safetynet-profiles-')),store=openStore(dir),origin='http://127.0.0.1:4310',server=createApp(store,{publicUrl:origin,requireIndependentPolicyApproval:review}).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});});for(const [id,subject]of [['one','employee'],['two','employee'],['other','other'],['forged',null]])store.db.prepare('INSERT INTO devices(id,name,user,token_hash,verified_subject) VALUES(?,?,?,?,?)').run(id,id,'employee',digest(id+'-synthetic-device-credential-123456789'),subject);async function api(path,method='GET',body,sub='author',role='admin'){const token=sub+'-synthetic-profile-session-123456789';store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(token),Date.now()+60000,JSON.stringify({sub,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{cookie:`sn_session=${token}`,origin,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}return {store,api,base:`http://127.0.0.1:${server.address().port}`};}
test('arbitrary user profiles follow verified subject across devices and never client user labels',async t=>{const f=await fixture(t),profile=UserProfileSchema.parse({id:'lab-reviewers',name:'Research reviewers',subjects:['employee'],blockedHosts:['blocked.example'],features:{downloads:'deny'}});assert.equal((await f.api('/api/admin/user-profiles/lab-reviewers','PUT',{expectedVersion:1,profile})).status,200);for(const id of ['one','two']){const p=verifyPolicy(signPolicy(f.store.effectivePolicy(id),f.store.get('keys').privateKey),f.store.publicKey());assert.equal(p.browser.allowDownloads,false);assert.equal(destinationAllowed('https://blocked.example/',p),false);assert.deepEqual(p.assignment.profiles,['lab-reviewers']);}for(const id of ['other','forged'])assert.equal(destinationAllowed('https://blocked.example/',f.store.effectivePolicy(id)),true);assert.equal((await f.api('/api/admin/user-profiles/lab-reviewers','DELETE',{expectedVersion:2})).status,200);assert.equal(destinationAllowed('https://blocked.example/',f.store.effectivePolicy('one')),true);});
test('delegation requires explicit signed ceiling and denies win across conflicting profiles',()=>{const p=defaultPolicy(),allow=UserProfileSchema.parse({id:'developer',name:'Developer',deviceIds:['one'],features:{devTools:'allow'}}),deny=UserProfileSchema.parse({id:'restricted',name:'Restricted',deviceIds:['one'],features:{devTools:'deny'}});assert.equal(applyUserProfiles(p,{profiles:[allow]}, {id:'one'}).browser.allowDevTools,false);p.userAccess.delegatableFeatures.devTools=true;assert.equal(applyUserProfiles(p,{profiles:[allow]},{id:'one'}).browser.allowDevTools,true);assert.equal(applyUserProfiles(p,{profiles:[allow,deny]},{id:'one'}).browser.allowDevTools,false);assert.deepEqual(applyUserProfiles(p,{profiles:[allow,deny]},{id:'one'}),applyUserProfiles(p,{profiles:[deny,allow]},{id:'one'}));});
test('domain categories and intersected host allowances fail closed for disjoint profiles',()=>{const p=defaultPolicy(),a=UserProfileSchema.parse({id:'a',name:'A',deviceIds:['one'],allowedHosts:['*.company.example'],blockedCategoryIds:['social']}),b=UserProfileSchema.parse({id:'b',name:'B',deviceIds:['one'],allowedHosts:['wiki.company.example']});const c={profiles:[a,b],categories:[{id:'social',name:'Social',hosts:['social.company.example']}]},r=applyUserProfiles(p,c,{id:'one'});assert.equal(destinationAllowed('https://wiki.company.example',r),true);assert.equal(destinationAllowed('https://social.company.example',r),false);b.allowedHosts=['other.example'];const disjoint=applyUserProfiles(p,c,{id:'one'});assert.equal(destinationAllowed('https://other.example',disjoint),false);assert.equal(destinationAllowed('https://denied.invalid',disjoint),false);});
test('profile publication enforces independent reviewers, role permissions, CAS, snapshots and restore',async t=>{const f=await fixture(t,true),profile=UserProfileSchema.parse({id:'executive',name:'Board',deviceIds:['one'],requireDocumentScan:true});const queued=await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.getRaw('userProfiles'),null);const body={payloadHash:queued.body.payloadHash,expectedVersion:1,approve:true};assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'operator')).status,403);assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'reviewer')).status,200);assert.equal((await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile})).status,409);assert.equal((await f.api('/api/admin/user-profiles/executive','DELETE',{expectedVersion:2},'auditor','auditor')).status,403);assert.equal((await f.api('/api/admin/policy/history/2')).body.userProfiles.profiles[0].id,'executive');const restore=await f.api('/api/admin/policy/restore','POST',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(restore.status,202);assert.equal((await f.api(`/api/admin/policy-reviews/${restore.body.reviewId}/approve`,'POST',{payloadHash:restore.body.payloadHash,expectedVersion:2,approve:true},'reviewer')).status,200);assert.deepEqual(f.store.getRaw('userProfiles').profiles,[]);});
test('enrollment cannot forge a verified SSO subject through client input or a named provisioning code',async t=>{const f=await fixture(t);const code='synthetic-provisioning-code-123456789';f.store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'employee');const r=await fetch(f.base+'/api/device/enroll',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code,name:'Forged label',platform:'win32',sub:'employee',verified_subject:'employee',user:'employee'})});assert.equal(r.status,200);const b=await r.json(),d=f.store.db.prepare('SELECT verified_subject,user FROM devices WHERE id=?').get(b.id);assert.equal(d.verified_subject,null);assert.equal(d.user,'employee');});