Files
safetynet/tests/incidents.test.mjs
T

9 lines
7.0 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';
async function fixture(t){const dir=mkdtempSync(join(tmpdir(),'safetynet-incidents-')),store=openStore(dir),origin='http://127.0.0.1:4310',server=createApp(store,{publicUrl:origin}).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});});return {store,api:async(path,method='GET',body,role='operator')=>{const token=`synthetic-incident-${role}-credential-1234567890`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(token),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}/api/admin/incidents${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()};}};}
test('incident creates once from immutable event and detail strips payloads',async t=>{const {store,api}=await fixture(t);store.event('device','upload.blocked',{ruleId:'confidential',host:'example.test',prompt:'secret synthetic prompt',document:'secret synthetic document'});const a=await api('','POST',{eventId:1,severity:'high'});assert.equal(a.status,201);assert.equal(a.body.revision,1);assert.equal(a.body.event.metadata.prompt,undefined);assert.equal(a.body.event.metadata.document,undefined);assert.equal((await api('','POST',{eventId:1})).status,409);assert.equal((await api('','POST',{eventId:999})).status,404);const d=await api('/'+a.body.id);assert.equal(d.body.history.length,1);assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=1').get().kind,'upload.blocked');assert.equal((await api('','POST',{eventId:2})).status,400);});
test('triage is revision bound and comments remain outside ordinary audit/SIEM',async t=>{const {store,api}=await fixture(t);store.event(null,'clipboard.blocked',{});const a=await api('','POST',{eventId:1});const id=a.body.id,comment='Contacted owner; synthetic investigation note.';const b=await api('/'+id,'PATCH',{expectedRevision:1,status:'investigating',assignee:'analyst-sub',comment});assert.equal(b.status,200);assert.equal(b.body.revision,2);assert.equal((await api('/'+id,'PATCH',{expectedRevision:1,status:'resolved'})).status,409);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,comment:'x'.repeat(1001)})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,comment:'\u0000bad'})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,document:'payload'})).status,400);const d=await api('/'+id);assert.equal(d.body.history[1].comment,comment);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='incident.updated'").get().metadata;assert.equal(audit.includes(comment),false);assert.equal(JSON.parse(audit).commentLength,comment.length);assert.equal(JSON.parse(audit).commentSha256,undefined);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,assignee:'bad\nsubject'})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,status:'resolved',assignee:null})).status,200);assert.equal((await api('/'+id,'PATCH',{expectedRevision:3,status:'open'})).status,200);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,4);});
test('auditor cannot triage and unrecognized routes never gain permissions',async t=>{const {store,api}=await fixture(t);store.event(null,'dns.blocked',{});const a=await api('','POST',{eventId:1});assert.equal((await api('','GET',null,'auditor')).status,200);assert.equal((await api('/'+a.body.id,'GET',null,'auditor')).status,200);assert.equal((await api('','POST',{eventId:1},'auditor')).status,403);assert.equal((await api('/'+a.body.id,'PATCH',{expectedRevision:1,status:'resolved'},'auditor')).status,403);assert.equal((await api('/'+a.body.id,'DELETE')).status,403);assert.equal((await api('/not-a-uuid')).status,400);});
test('filters and pagination are bounded and deterministic',async t=>{const {store,api}=await fixture(t);for(let i=0;i<3;i++){store.event(null,'upload.blocked',{});const source=store.db.prepare("SELECT id FROM events WHERE kind='upload.blocked' ORDER BY id DESC LIMIT 1").get();await api('','POST',{eventId:source.id,severity:i===0?'low':'high'});}const first=await api('?limit=1&severity=high');assert.equal(first.body.incidents.length,1);assert.ok(first.body.nextBefore);const next=await api('?limit=1&severity=high&before='+first.body.nextBefore);assert.equal(next.body.incidents.length,1);assert.notEqual(first.body.incidents[0].id,next.body.incidents[0].id);assert.equal(next.body.nextBefore,null);assert.equal((await api('?status=resolved')).body.incidents.length,0);assert.equal((await api('?limit=101')).status,400);assert.equal((await api('?status=bogus')).status,400);});
test('audit failure rolls back case creation and triage history atomically',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{});const emit=store.event;store.event=()=>{throw Error('synthetic audit unavailable');};assert.equal((await api('','POST',{eventId:1})).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_cases').get().n,0);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,0);store.event=emit;const created=await api('','POST',{eventId:1});store.event=()=>{throw Error('synthetic audit unavailable');};assert.equal((await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved'})).status,500);assert.equal(store.db.prepare('SELECT revision,status FROM incident_cases').get().revision,1);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,1);store.event=emit;});
test('real resolved incident retains source and immutable history through event retention',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{ruleId:'confidential'});const source=store.db.prepare('SELECT id FROM events ORDER BY id DESC LIMIT 1').get().id;const created=await api('','POST',{eventId:source});const resolved=await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved',comment:'Synthetic resolution note'});assert.equal(resolved.status,200);store.db.prepare('UPDATE events SET time=?').run('2020-01-01T00:00:00.000Z');const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');const result=pruneAuditEvents(store,{days:30,exportedThrough:100,dryRun:false});assert.ok(result.eligible>0);const detail=await api('/'+created.body.id);assert.equal(detail.status,200);assert.equal(detail.body.status,'resolved');assert.equal(detail.body.event.id,source);assert.equal(detail.body.history.length,2);assert.equal(detail.body.history[1].comment,'Synthetic resolution note');assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=?').get(source).kind,'upload.blocked');});