/** * Host-side capability audit log (#plugins, L1 hardening): resource projection, * which methods are auditable, and the per-plugin hash chain. * * Plan 3j Task 3 — converted onto `PluginCapabilityAuditRepository`. R-hash-chain: * `legacyAppendAudit` below is a byte-for-byte replica of the PRE-CONVERSION * `appendAudit` body (a raw `better-sqlite3` INSERT, the exact hash construction * this task's `plugin-audit.ts` now reproduces through the repository) — it exists * SOLELY so these tests can produce a "legacy-written" chain to replay against the * converted code, without keeping a second, unused copy of the production function * around. */ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; import crypto from 'node:crypto'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; import { appendAudit, readAudit, readAuditForUser, auditResource, isAuditable, pruneAudit, verifyChain } from '../../../src/nest/plugins/host/plugin-audit'; const testDb = createSnapshotTestDb(); let t: TestOrm; let audit: PluginCapabilityAuditRepository; beforeAll(async () => { t = await createTestOrm(testDb); audit = t.repo(PluginCapabilityAudit); }); beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); interface LegacyEntry { pluginId: string; actingUserId?: number; method: string; resource?: string | null; code: string; } /** The pre-conversion `appendAudit` body, verbatim, against a raw connection. */ function legacyAppendAudit(prevHash: string, e: LegacyEntry): { ts: string; hash: string } { const ts = new Date().toISOString(); const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]); const hash = crypto.createHash('sha256').update(prevHash + row).digest('hex'); testDb .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') .run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prevHash || null, hash); return { ts, hash }; } /** Every row for a plugin, oldest first — the shape `verifyChain` needs. */ function chainRows(pluginId: string) { return testDb .prepare('SELECT plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id ASC') .all(pluginId) as Array<{ plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }>; } describe('auditResource + isAuditable', () => { it('projects a resource key from method + params (never raw params)', () => { expect(auditResource('trips.getById', { tripId: 12 })).toBe('trip:12'); expect(auditResource('users.getById', { id: 3 })).toBe('user:3'); expect(auditResource('ws.broadcastToTrip', { tripId: 7 })).toBe('trip:7'); expect(auditResource('ws.broadcastToUser', { userId: 9 })).toBe('user:9'); expect(auditResource('db.query', { sql: 'x' })).toBeNull(); }); it('audits core-data + ws, not a plugin own-db call', () => { expect(isAuditable('trips.getReservations')).toBe(true); expect(isAuditable('users.getById')).toBe(true); expect(isAuditable('packing.list')).toBe(true); expect(isAuditable('files.list')).toBe(true); expect(isAuditable('ws.broadcastToTrip')).toBe(true); expect(isAuditable('db.query')).toBe(false); expect(isAuditable('db.migrate')).toBe(false); }); it('resolves packing/files reads to their trip resource', () => { expect(auditResource('packing.list', { tripId: 3 })).toBe('trip:3'); expect(auditResource('files.list', { tripId: 3 })).toBe('trip:3'); }); }); describe('appendAudit hash chain (converted, through PluginCapabilityAuditRepository)', () => { it('chains each entry off the previous hash (per plugin)', async () => { await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); const rows = testDb.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>; expect(rows).toHaveLength(2); expect(rows[0].prev_hash).toBeNull(); expect(rows[1].prev_hash).toBe(rows[0].hash); // chain links expect(rows[1].hash).not.toBe(rows[0].hash); }); it('keeps separate chains per plugin', async () => { await appendAudit(audit, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); const b = testDb.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null }; expect(b.prev_hash).toBeNull(); // b's first entry doesn't chain off a's }); it('records denials too (code is the error code)', async () => { await appendAudit(audit, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' }); const row = (await readAudit(audit, 'p'))[0]; expect(row.code).toBe('RESOURCE_FORBIDDEN'); }); it("readAuditForUser returns one user's actions across ALL plugins, newest first, with the plugin name", async () => { testDb.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run(); await appendAudit(audit, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' }); await appendAudit(audit, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user const mine = await readAuditForUser(audit, 42); expect(mine).toHaveLength(2); // only user 42's rows, across both plugins expect(mine[0]).toMatchObject({ plugin_id: 'flight', plugin_name: 'Flight Tracker', method: 'reservations.create' }); // newest first expect(mine[1]).toMatchObject({ plugin_id: 'koffi', plugin_name: 'Koffi' }); expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && r.resource === 'trip:2')).toBe(false); // never another user's }); it('readAudit returns newest first with the projected fields only', async () => { await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); const rows = await readAudit(audit, 'p'); expect(rows[0]).toHaveProperty('method', 'trips.getById'); expect(rows[0]).not.toHaveProperty('prev_hash'); // internal chain fields not exposed }); it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', async () => { for (let i = 0; i < 50; i++) await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }); await appendAudit(audit, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' }); await pruneAudit(audit, 'p', 10); const rows = testDb.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>; expect(rows).toHaveLength(10); expect(rows[rows.length - 1].resource).toBe('trip:49'); // newest kept // each retained row is still self-consistent: hash === sha256(prev_hash + row-content) // (proven by re-appending on top — the chain continues from the surviving tip) await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' }); expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); // pruning one plugin never touches another's rows expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 }); await expect(pruneAudit(audit, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); }); }); describe('R-hash-chain: replay, extension, mutation', () => { it('AUDITCHAIN-001 REPLAY+EXTENSION: verifies a legacy-produced chain, then extends it through the converted appendAudit and the WHOLE chain stays consistent', async () => { // 1. Write three rows the way the PRE-CONVERSION code did: raw SQL, no repository. let prev = ''; for (let i = 0; i < 3; i++) { const { hash } = legacyAppendAudit(prev, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }); prev = hash; } // 2. REPLAY: the converted verification logic (verifyChain) accepts the legacy-written rows. expect(verifyChain(chainRows('legacy-p'))).toBe(true); // 3. EXTENSION: append two more rows through the CONVERTED appendAudit, on top of the // legacy tip — the repository reads the legacy row's hash as its own prev_hash. await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:3', code: 'ok' }); await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:4', code: 'ok' }); const rows = chainRows('legacy-p'); expect(rows).toHaveLength(5); expect(rows[3].prev_hash).toBe(rows[2].hash); // the converted append linked onto the legacy tip // 4. The WHOLE chain (3 legacy rows + 2 converted rows) verifies end to end. expect(verifyChain(rows)).toBe(true); }); it('AUDITCHAIN-002 MUTATION: a row whose stored hash omits one field from the hash input fails verification', async () => { // A row constructed EXACTLY like appendAudit's real one, except `resource` is // dropped from the JSON array fed to sha256 — the drift R-hash-chain warns // never errors anywhere on write, only on a verification pass. const pluginId = 'mutant'; const actingUserId = 1; const method = 'trips.getById'; const resource = 'trip:1'; const code = 'ok'; const ts = new Date().toISOString(); const mutatedRow = JSON.stringify([pluginId, actingUserId, method, code, ts]); // resource OMITTED const mutatedHash = crypto.createHash('sha256').update('' + mutatedRow).digest('hex'); testDb .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') .run(pluginId, actingUserId, method, resource, code, ts, null, mutatedHash); expect(verifyChain(chainRows(pluginId))).toBe(false); // Control: the same row, hashed with the REAL (resource-included) construction, verifies. testDb.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(pluginId); legacyAppendAudit('', { pluginId, actingUserId, method, resource, code }); expect(verifyChain(chainRows(pluginId))).toBe(true); }); });