import test from 'node:test'; import assert from 'node:assert/strict'; import {DatabaseSync} from 'node:sqlite'; import {initializeAuditIntegrity} from '../apps/server/audit-integrity.mjs'; function setup(legacy=false){const db=new DatabaseSync(':memory:');db.exec('CREATE TABLE events(id INTEGER PRIMARY KEY,time TEXT,device_id TEXT,kind TEXT,metadata TEXT)');if(legacy)db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(1,'2026-01-01',null,'old','{}');const audit=initializeAuditIntegrity(db);function insert(id){db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(id,'2026-01-01','device','test','{"bytes":4}');audit.append(db.prepare('SELECT * FROM events WHERE id=?').get(id));}return {db,audit,insert};} test('audit detects event mutations and returns reconstructable proof',()=>{const {db,audit,insert}=setup();insert(1);assert.equal(audit.verify().valid,true);assert.equal(audit.verify().authenticated,false);assert.equal(audit.entries({}).entries[0].type,'event');db.exec("UPDATE events SET metadata='{}' WHERE id=1");assert.ok(audit.verify().errors.includes('event-digest'));db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(2,'now',null,'audit.retention-applied','{}');audit.append(db.prepare('SELECT * FROM events WHERE id=2').get());assert.throws(()=>audit.authorizePrune([1],2),/changed/);db.close();}); test('unauthorized deletion and anchored tail truncation are detected',()=>{const {db,audit,insert}=setup();insert(1);insert(2);const expectedHead=audit.head();db.exec('DELETE FROM events WHERE id=2');assert.ok(audit.verify().errors.includes('missing-event'));db.exec('DELETE FROM audit_integrity WHERE seq=2');assert.ok(audit.verify({expectedHead}).errors.includes('anchor-mismatch'));db.close();}); test('authorized retention keeps digest verification without retaining plaintext',()=>{const {db,audit,insert}=setup();insert(1);db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(2,'now',null,'audit.retention-applied','{}');audit.append(db.prepare('SELECT * FROM events WHERE id=2').get());audit.authorizePrune([1],2);db.exec('DELETE FROM events WHERE id=1');assert.equal(audit.verify().valid,true);assert.equal(audit.entries({}).entries[2].body.includes('bytes'),false);db.close();}); test('migration explicitly marks legacy and restart does not backfill orphan events',()=>{const {db,audit}=setup(true);assert.equal(audit.verify().legacyEvents,1);db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(2,'now',null,'orphan','{}');assert.throws(()=>initializeAuditIntegrity(db),/verification failed/);assert.ok(audit.verify().errors.includes('uncovered-events'));assert.equal(audit.head().sequence,1);db.close();}); test('chain-body mutation, sequence gaps and malformed retention are detected',()=>{const {db,audit,insert}=setup();insert(1);db.exec("UPDATE audit_integrity SET body='{}' WHERE seq=1");assert.equal(audit.verify().valid,false);assert.throws(()=>audit.entries({limit:1001}));db.close();}); import {generateKeyPairSync} from 'node:crypto'; import {mkdtempSync,writeFileSync,rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {execFileSync} from 'node:child_process'; test('ML-DSA-87 signatures authenticate entries and require protected signer on restart',()=>{const dir=mkdtempSync(join(tmpdir(),'safetynet-audit-test-')),file=join(dir,'key.pem'),keys=generateKeyPairSync('ml-dsa-87');let db;try{writeFileSync(file,keys.privateKey.export({type:'pkcs8',format:'pem'}),{mode:0o600});if(process.platform==='win32'){execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_AUDIT_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_AUDIT_KEY:file},stdio:'pipe'});}db=new DatabaseSync(':memory:');db.exec('CREATE TABLE events(id INTEGER PRIMARY KEY,time TEXT,device_id TEXT,kind TEXT,metadata TEXT)');const audit=initializeAuditIntegrity(db,{signingKeyFile:file});db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(1,'now',null,'test','{}');audit.append(db.prepare('SELECT * FROM events').get());const trustedPublicKey=keys.publicKey.export({type:'spki',format:'pem'}).toString(),expectedHead=audit.head();assert.equal(audit.verify({trustedPublicKey,expectedHead}).externallyAnchored,true);assert.equal(audit.verify().authenticated,true);assert.throws(()=>initializeAuditIntegrity(db),/unavailable/);assert.equal(initializeAuditIntegrity(db,{signingKeyFile:file}).verify().valid,true);db.exec("UPDATE audit_integrity SET signature='AAAA' WHERE type='event'");assert.ok(audit.verify().errors.includes('signature'));const body=JSON.parse(db.prepare("SELECT body FROM audit_integrity WHERE type='event'").get().body);body.legacy=true;db.prepare("UPDATE audit_integrity SET body=?,signature=NULL,public_key=NULL WHERE type='event'").run(JSON.stringify(body));assert.ok(audit.verify().errors.includes('legacy-after-seal'));db.exec('DELETE FROM audit_integrity');assert.ok(audit.verify().errors.includes('missing-baseline-seal'));db.exec('DELETE FROM events');assert.throws(()=>initializeAuditIntegrity(db,{signingKeyFile:file}),/verification failed/);assert.equal(db.prepare('SELECT count(*) AS n FROM audit_integrity').get().n,0);}finally{db?.close();rmSync(dir,{recursive:true,force:true});}});