82 lines
10 KiB
JavaScript
82 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {createServer} from 'node:http';
|
|
import {mkdtempSync,rmSync} from 'node:fs';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
import {openStore} from '../apps/server/store.mjs';
|
|
import {createApp} from '../apps/server/app.mjs';
|
|
import {librarySnapshotHash,validateLibraryPlan,LibraryPolicySchema} from '../packages/core/library.mjs';
|
|
import {verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs';
|
|
|
|
const bookmark={id:'personal',title:'Public project',url:'https://example.com/project',folder:''};
|
|
const tab={id:'tab-1',title:'Public documentation',url:'https://docs.example.com/path?session=private#secret'};
|
|
const plan={bookmarks:[{id:'personal',folder:'Projects'}],tabs:[{id:'tab-1',group:'Research'}]};
|
|
async function fixture(t){
|
|
const dir=mkdtempSync(join(tmpdir(),'library-test-')),store=openStore(dir),adminToken='library-fixture-admin';let output=JSON.stringify(plan),calls=0,prompt='',gate=null;
|
|
const provider=createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);prompt=JSON.parse(Buffer.concat(chunks).toString()).messages[0].content;calls++;if(gate)await gate;res.setHeader('content-type','application/json');res.end(JSON.stringify({choices:[{message:{content:output}}]}));});
|
|
await new Promise(r=>provider.listen(0,'127.0.0.1',r));
|
|
const config={adminToken,publicUrl:'http://127.0.0.1',secure:false,aiBaseUrl:`http://127.0.0.1:${provider.address().port}`,aiModel:'fixture',aiEndpointId:'default'};
|
|
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));
|
|
t.after(async()=>{await Promise.all([new Promise(r=>server.close(r)),new Promise(r=>provider.close(r))]);store.db.close();rmSync(dir,{recursive:true,force:true});});
|
|
async function api(route,body,token=adminToken){const r=await fetch(`http://127.0.0.1:${server.address().port}${route}`,{method:body?'POST':'GET',headers:{authorization:`Bearer ${token}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}
|
|
const code=(await api('/api/admin/enrollments',{user:'fixture-user'})).body.code,device=(await api('/api/device/enroll',{code,name:'Fixture',platform:'win32'},'')).body;
|
|
const p=store.get('policy');p.ai={...p.ai,enabled:true,trustMode:'local',model:'fixture'};p.library={aiSuggestionsEnabled:true,aiAutoApplyEnabled:false};store.put('policy',p);
|
|
await api('/api/device/bookmarks',{action:'add',expectedRevision:0,policyVersion:1,bookmark},device.token);
|
|
const request={policyVersion:1,bookmarkRevision:1,tabs:[tab],snapshotHash:librarySnapshotHash([tab])};
|
|
return {store,device,api,request,propose:()=>api('/api/device/library/propose',request,device.token),setOutput:v=>{output=v;},setGate:v=>{gate=v;},calls:()=>calls,prompt:()=>prompt};
|
|
}
|
|
test('AI library defaults disabled and strict organization permits only existing item IDs and labels',()=>{
|
|
assert.deepEqual(LibraryPolicySchema.parse({}),{aiSuggestionsEnabled:false,aiAutoApplyEnabled:false});assert.deepEqual(validateLibraryPlan(plan,{bookmarks:[bookmark],tabs:[tab]}),plan);
|
|
for(const v of [{...plan,bookmarks:[{...plan.bookmarks[0],url:'https://evil.test'}]},{...plan,tabs:[{id:'other',group:'Research'}]},{...plan,bookmarks:[]},{...plan,tabs:[{id:tab.id,group:'a'.repeat(41)}]},{...plan,bookmarks:[{id:bookmark.id,folder:'bad\nlabel'}]}])assert.throws(()=>validateLibraryPlan(v,{bookmarks:[bookmark],tabs:[tab]}));
|
|
});
|
|
test('approved gateway proposes metadata-only AI plan and atomically applies signed personal bookmarks',async t=>{
|
|
const f=await fixture(t),result=await f.propose();assert.equal(result.status,200);assert.ok(f.prompt().includes('docs.example.com'));assert.ok(!f.prompt().includes('/path'));assert.ok(!f.prompt().includes('session=private'));assert.ok(!f.prompt().includes('/project'));
|
|
const applied=await f.api('/api/device/library/apply',{proposalId:result.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash},f.device.token);assert.equal(applied.status,200);assert.equal(applied.body.revision,2);assert.equal(applied.body.items[0].folder,'Projects');assert.deepEqual(applied.body.tabs,plan.tabs);
|
|
assert.equal(verifyBookmarkSnapshot(applied.body.bookmarks,f.device.publicKey,{deviceId:f.device.id,policyVersion:1,minimumRevision:2}).items[0].folder,'Projects');
|
|
assert.equal((await f.api('/api/device/library/apply',{proposalId:result.body.proposalId,policyVersion:1,expectedRevision:2,snapshotHash:f.request.snapshotHash},f.device.token)).status,404);
|
|
const events=f.store.db.prepare("SELECT metadata FROM events WHERE kind LIKE 'library.%'").all();assert.equal(events.length,2);assert.ok(events.every(e=>!e.metadata.includes(bookmark.title)&&!e.metadata.includes(bookmark.url)&&!e.metadata.includes('Projects')));
|
|
});
|
|
test('central disable, hidden library, expired policy, malformed snapshot and disabled automatic application fail closed',async t=>{
|
|
const f=await fixture(t),p=f.store.get('policy');p.library.aiSuggestionsEnabled=false;f.store.put('policy',p);assert.equal((await f.propose()).status,403);assert.equal(f.calls(),0);
|
|
p.library.aiSuggestionsEnabled=true;p.browser.settings.hiddenSections=['library'];f.store.put('policy',p);assert.equal((await f.propose()).status,403);p.browser.settings.hiddenSections=[];f.store.put('policy',p);
|
|
assert.equal((await f.api('/api/device/library/propose',{...f.request,snapshotHash:'0'.repeat(64)},f.device.token)).status,409);const r=await f.propose();assert.equal(r.status,200);
|
|
assert.equal((await f.api('/api/device/library/apply',{proposalId:r.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash,mode:'automatic'},f.device.token)).status,403);
|
|
p.expiresAt=new Date(Date.now()-1000).toISOString();f.store.put('policy',p);assert.equal((await f.propose()).status,403);
|
|
});
|
|
test('malicious model output and protected metadata rejected before proposal or transmission',async t=>{
|
|
const f=await fixture(t);for(const bad of [{...plan,bookmarks:[{id:'managed',folder:'Work'}]},{...plan,delete:['personal']},{...plan,tabs:[{...plan.tabs[0],url:'https://evil.test'}]}]){f.setOutput(JSON.stringify(bad));assert.notEqual((await f.propose()).status,200);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);}
|
|
const before=f.calls(),p=f.store.get('policy');p.dlp.rules.push({id:'confidential-title',label:'Protected title',kind:'literal',value:bookmark.title,action:'block'});f.store.put('policy',p);assert.equal((await f.propose()).status,403);assert.equal(f.calls(),before);
|
|
});
|
|
test('stale bookmark revision, policy generation and managed collisions cannot apply or partially change library',async t=>{
|
|
const f=await fixture(t),r=await f.propose();assert.equal(r.status,200);const apply={proposalId:r.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash};
|
|
assert.equal((await f.api('/api/device/library/apply',{...apply,snapshotHash:'0'.repeat(64)},f.device.token)).status,409);
|
|
f.store.db.prepare('UPDATE bookmarks SET revision=2 WHERE device_id=?').run(f.device.id);assert.equal((await f.api('/api/device/library/apply',apply,f.device.token)).status,409);assert.equal(f.store.personalBookmarks(f.device.id).items[0].folder,'');
|
|
f.store.db.prepare('UPDATE bookmarks SET revision=1 WHERE device_id=?').run(f.device.id);const p=f.store.get('policy');p.version=2;f.store.put('policy',p);assert.equal((await f.api('/api/device/library/apply',{...apply,policyVersion:2},f.device.token)).status,409);
|
|
p.version=1;p.browser.bookmarks.managed=[bookmark];f.store.put('policy',p);assert.equal((await f.api('/api/device/library/apply',apply,f.device.token)).status,403);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
|
});
|
|
test('automatic organization is separately enabled and existing proposals cannot survive a remote disable',async t=>{
|
|
const f=await fixture(t),p=f.store.get('policy');p.library.aiAutoApplyEnabled=true;f.store.put('policy',p);const r=await f.propose();assert.equal(r.status,200);
|
|
const body={proposalId:r.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash,mode:'automatic'};
|
|
p.library.aiSuggestionsEnabled=false;f.store.put('policy',p);assert.equal((await f.api('/api/device/library/apply',body,f.device.token)).status,403);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
|
p.library.aiSuggestionsEnabled=true;f.store.put('policy',p);const applied=await f.api('/api/device/library/apply',body,f.device.token);assert.equal(applied.status,200);assert.equal(applied.body.revision,2);
|
|
});
|
|
test('model output DLP and pending proposal bounds are enforced without storing rejected model data',async t=>{
|
|
const f=await fixture(t),p=f.store.get('policy');p.dlp.rules.push({id:'protected-output',label:'Protected',kind:'literal',value:'Projects',action:'block'});f.store.put('policy',p);assert.equal((await f.propose()).status,403);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
|
p.dlp.rules=[];f.store.put('policy',p);for(let n=0;n<3;n++)assert.equal((await f.propose()).status,200);const before=f.calls();assert.equal((await f.propose()).status,409);assert.equal(f.calls(),before);
|
|
});
|
|
test('concurrent proposal requests are bounded and policy changes during provider execution invalidate results',async t=>{
|
|
const f=await fixture(t);let release;f.setGate(new Promise(r=>{release=r;}));const pending=f.propose();
|
|
for(let n=0;n<100&&f.calls()===0;n++)await new Promise(r=>setTimeout(r,10));assert.equal(f.calls(),1);
|
|
assert.equal((await f.propose()).status,409);assert.equal(f.calls(),1);
|
|
const p=f.store.get('policy');p.version++;f.store.put('policy',p);const rejected=await pending;release();assert.equal(rejected.status,409);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
|
});
|
|
|
|
test('provider deadline returns fixed 504 before held provider output is released',async t=>{
|
|
const f=await fixture(t);let release,deadline;f.setGate(new Promise(resolve=>{release=resolve;}));
|
|
const timeout=globalThis.setTimeout;
|
|
t.mock.method(globalThis,'setTimeout',(callback,milliseconds,...args)=>{if(milliseconds===60000){deadline=callback;return timeout(()=>{},60000);}return timeout(callback,milliseconds,...args);});
|
|
const pending=f.propose();for(let n=0;n<100&&f.calls()===0;n++)await new Promise(resolve=>timeout(resolve,10));assert.equal(f.calls(),1);assert.equal(typeof deadline,'function');
|
|
deadline();const result=await pending;release();assert.equal(result.status,504);assert.equal(result.body.error,'Request failed');assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
|
});
|