Files
MikrotikManager/backend/src/services/database-restore-audit.test.ts
T

111 lines
10 KiB
TypeScript

import test from 'node:test'
import assert from 'node:assert/strict'
import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import {randomUUID} from 'node:crypto'
import {EventEmitter} from 'node:events'
import type {Pool} from 'pg'
import {createRequire} from 'node:module'
import {runInNewContext} from 'node:vm'
import {reconcileRestoreReceiptAudit,inspectRestoreReceiptAudit,reconcileDatabaseRestoreAudits} from './database-restore-audit.js'
const here=path.dirname(fileURLToPath(import.meta.url)),backend=path.resolve(here,'../..'),temp=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript')
async function own(work:(root:string,operation:string,receipt:Record<string,unknown>)=>Promise<void>,old=false){
await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-restore-audit-'))
try{
const operation=randomUUID(),archive=randomUUID(),core=randomUUID(),stamp=new Date(Date.now()-(old?181*86400000:1000)).toISOString()
const receipt={operationId:operation,archiveRef:archive,state:'applied',startedAt:stamp,automaticRetry:false,finishedAt:stamp,accessRevoked:true,warnings:[]}
await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,11));await mkdir(path.join(root,'restore-vault'))
await writeFile(path.join(root,'database-maintenance.jsonl'),['intent','applied'].map(state=>JSON.stringify({operationId:core,state,at:stamp})).join('\n')+'\n')
await writeFile(path.join(root,'restore-vault',archive+'.used'),JSON.stringify({operationId:operation,at:stamp}))
await writeFile(path.join(root,'restore-vault',operation+'.receipt.json'),JSON.stringify(receipt))
await work(root,operation,receipt)
}finally{assert(path.dirname(root)===temp&&path.basename(root).startsWith('owned-restore-audit-'));await rm(root,{recursive:true,force:true})}
}
type Row={actor_id:string;connection_id:null;action:string;payload:unknown;created_at?:string}
function model(){
const rows:Row[]=[],queries:string[]=[],releases:boolean[]=[];let lostCommit=false,insertFailure=false,releaseFailure=false,offFailure=false,inserts=0
const pool={connect:async()=>{
let pending:Row[]=[]
const client=new EventEmitter(),originalOff=client.off.bind(client)
return Object.assign(client,{off:(name:string,listener:()=>void)=>{if(offFailure)throw Error('Synthetic listener cleanup failure');return originalOff(name,listener)},release:(bad:boolean)=>{releases.push(bad);if(releaseFailure)throw Error('Synthetic release failure')},query:async(config:{text:string;values?:unknown[]})=>{
const text=config.text,values=config.values??[];queries.push(text)
if(text.startsWith('SELECT audit_retention'))return {rows:[{days:180,now:String(Date.now())}],command:'SELECT'}
if(text.startsWith('SELECT actor_id'))return {rows:rows.filter(row=>(row.payload as {operationId:string}).operationId===values[1]).slice(0,2),command:'SELECT'}
if(text.startsWith('INSERT')){if(insertFailure)throw Error('Synthetic audit unavailable');inserts++;pending.push({actor_id:String(values[0]),action:String(values[1]),connection_id:null,payload:JSON.parse(String(values[2])),created_at:String(values[3])});return {rows:[],command:'INSERT'}}
if(text==='COMMIT'){rows.push(...pending);pending=[];if(lostCommit){lostCommit=false;throw Error('Synthetic COMMIT delivery loss')}return {rows:[],command:'COMMIT'}}
if(text==='ROLLBACK')pending=[]
return {rows:[],command:text.split(' ')[0]}
}})
}} as unknown as Pick<Pool,'connect'>
return {pool,rows,queries,releases,inserts:()=>inserts,loseCommit:()=>{lostCommit=true},failInsert:()=>{insertFailure=true},failRelease:()=>{releaseFailure=true},failOff:()=>{offFailure=true}}
}
test('live and startup audit writers converge through read-first without changing external receipt',()=>own(async(root,id,receipt)=>{
const m=model(),file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file)
const live=await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner');assert.equal(live.status,'imported');assert.equal(live.originalActorKnown,true)
assert.equal(m.rows[0].action,'database.restore.receipt');assert.deepEqual(m.rows[0].payload,receipt);assert.equal(m.rows[0].actor_id,'actual-owner')
await reconcileDatabaseRestoreAudits(m.pool,root);assert.equal(m.inserts(),1)
const status=await inspectRestoreReceiptAudit(m.pool,root,id);assert.equal(status.status,'already-recorded');assert.equal(m.inserts(),1)
assert(m.queries.some(q=>q==='BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY'));assert(m.queries.every(q=>!/^CREATE|^ALTER|^DROP|pg_restore|psql/.test(q)))
assert.deepEqual(await readFile(file),before)
}))
test('legacy missing audit imports exact receipt evidence under system importer without inventing human',()=>own(async(root,id)=>{
const m=model();await reconcileDatabaseRestoreAudits(m.pool,root)
assert.equal(m.rows.length,1);assert.equal(m.rows[0].actor_id,'database-maintenance-reconciler');assert.equal(m.rows[0].action,'database.restore.receipt.recovered')
assert.equal((m.rows[0].payload as {originalActorKnown:boolean}).originalActorKnown,false)
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner')).status,'already-recorded');assert.equal(m.inserts(),1)
}))
test('lost audit COMMIT destroys owner and reconciles observed existing row without another insert',()=>own(async(root,id)=>{
const m=model();m.loseCommit()
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'unconfirmed');assert.equal(m.releases[0],true)
assert.equal(m.queries.filter(value=>value==='ROLLBACK').length,0,'Uncertain COMMIT owner must be destroyed, not rolled back or reused')
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'already-recorded');assert.equal(m.inserts(),1)
}))
test('conflicting or duplicate audit evidence is never treated as imported',()=>own(async(root,id)=>{
const m=model();m.rows.push({actor_id:'foreign',connection_id:null,action:'database.restore.receipt',payload:{operationId:id,foreign:true}})
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'conflict');assert.equal(m.inserts(),0)
await assert.rejects(reconcileDatabaseRestoreAudits(m.pool,root),error=>error instanceof Error&&'code'in error&&error.code==='DATABASE_RESTORE_AUDIT_PENDING')
}))
test('audit retention is not reset by receipt reimport',()=>own(async(root,id)=>{
const m=model();assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'outside-retention');assert.equal(m.inserts(),0)
},true))
test('audit outage remains pending and never overwrites fsynced applied receipt',()=>own(async(root,id)=>{
const m=model();m.failInsert();const file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file)
assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'pending');assert.deepEqual(await readFile(file),before)
}))
for(const fault of ['release','listener'])test('confirmed audit COMMIT survives '+fault+' cleanup failure without relabeling restore',()=>own(async(root,id)=>{
const m=model();if(fault==='release')m.failRelease();else m.failOff()
const file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file)
const result=await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner')
assert.equal(result.status,'imported');assert.equal(result.cleanupUnconfirmed,true);assert.equal(m.rows.length,1)
assert.equal(m.releases.length,1,'Listener cleanup failure must not bypass actual owner release')
assert.deepEqual(await readFile(file),before)
}))
for(const legacyImport of [false,true])test('actual bootstrap audit follows migrations and legacy import='+legacyImport+' before business startup',()=>own(async(root)=>{
const source=await readFile(path.join(backend,'src/db/bootstrap.ts'),'utf8'),events:string[]=[],exports:Record<string,unknown>={}
const query=async()=>{events.push('sql');return {rows:[{sqlite_imported_at:'done'}]}},client={query,release:()=>{}}
const modules:Record<string,unknown>={
'./index.js':{pool:{query,connect:async()=>client}},'./migrate.js':{applySqlMigrations:async()=>events.push('migrations')},
'./partitions.js':{dropExpiredPartitions:async()=>events.push('drop'),ensurePartitionsAround:async()=>events.push('partitions')},
'./sqlite-import.js':{shouldImportSqlite:async()=>legacyImport,sqliteFileLooksPresent:()=>false,importSqliteToPostgres:async()=>{events.push('import');return {durationMs:0,tables:{}}}},
'../config.js':{env:{DATABASE_PATH:'synthetic'}},'../modules/mcp/security.js':{mcpStateDirectory:()=>root},
'../services/database-maintenance-startup.js':{assertDatabaseMaintenanceStartup:async()=>events.push('guard')},
'../services/database-restore-audit.js':{reconcileDatabaseRestoreAudits:async()=>{events.push('audit');throw Error('Pending audit import')}},
}
runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require:(name:string)=>{assert(name in modules);return modules[name]},console})
await assert.rejects((exports.initDatabase as ()=>Promise<void>)(),/Pending audit import/)
assert.equal(events[0],'guard');assert.equal(events.at(-1),'audit');assert(events.indexOf('migrations')<events.indexOf('audit'));if(legacyImport)assert(events.indexOf('import')<events.indexOf('audit'))
}))
test('live audit recovery call is outside the catch that can relabel restore',async()=>{
const source=await readFile(path.join(backend,'src/modules/mcp/database-restore.ts'),'utf8'),tree=ts.createSourceFile('restore.ts',source,ts.ScriptTarget.ES2022,true)
let callAncestors:unknown[]=[]
function visit(node:{kind:number;expression?:{getText:()=>string};getChildren:()=>unknown[]},ancestors:unknown[]=[]){
if(node.kind===ts.SyntaxKind.CallExpression&&node.expression?.getText()==='reconcileRestoreReceiptAudit')callAncestors=ancestors
for(const child of node.getChildren())visit(child as typeof node,[...ancestors,node])
}
visit(tree)
const tries=callAncestors.filter((node:unknown)=>(node as {kind:number}).kind===ts.SyntaxKind.TryStatement) as {getText:()=>string}[]
assert.equal(tries.length,1);assert(!tries[0].getText().includes('state: attempted ? "unknown"'))
assert(source.includes('restoreActorId=connection.owner_id'))
})