/** * AuditService 单元测试(P1-14 测试基线扩充) * 覆盖:链式哈希生成与验证、篡改检测、resetChainCache、query 过滤矩阵、 * CSV/JSONL 导出转义、logToolCall/logLLMRequest/logSessionEnd 等便捷方法。 * * 注意:better-sqlite3 为原生模块——若本机安装的构建与系统 Node ABI 不匹配, * 测试自动跳过(skipIf),避免误报失败。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { createHash } from 'crypto'; import log from 'electron-log'; // better-sqlite3 加载失败时(ABI 不匹配)自动跳过整个套件 // 注意:require 成功不代表原生绑定可用(Electron ABI vs 系统 Node ABI), // 需实际构造一次内存库验证 let dbAvailable = true; let Database: typeof import('better-sqlite3'); try { // eslint-disable-next-line @typescript-eslint/no-require-imports Database = require('better-sqlite3'); const probe = new Database(':memory:'); probe.close(); } catch { dbAvailable = false; } // electron-log 在纯 Node 环境静默(false = 禁用该 transport) log.transports.console.level = false; log.transports.file.level = false; describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => { let dir: string; let db: any; let service: any; beforeAll(async () => { const { AuditService } = await import('../../services/audit.service'); dir = mkdtempSync(join(tmpdir(), 'metona-audit-')); db = new Database(join(dir, 'test.db')); db.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, iteration INTEGER, event_type TEXT NOT NULL, actor TEXT NOT NULL DEFAULT 'system', target TEXT NOT NULL, details TEXT, outcome TEXT, duration_ms INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), prev_hash TEXT, current_hash TEXT ); CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit_logs BEGIN SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY.'); END; `); service = new AuditService(() => db); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } rmSync(dir, { recursive: true, force: true }); }); it('写入审计日志并生成链式哈希', () => { service.log({ sessionId: 's1', eventType: 'tool_call', actor: 'agent', target: 'read_file', details: { file: 'a.ts' }, outcome: 'success', }); const rows = db.prepare('SELECT * FROM audit_logs').all(); expect(rows.length).toBe(1); expect(rows[0].current_hash).toBeTruthy(); // 首条 prev_hash 为 64 个 0 的创世哨兵(getLastHash 空表回退值) expect(rows[0].prev_hash).toBe('0'.repeat(64)); }); it('第二条记录的 prev_hash 链接前一条', () => { service.log({ sessionId: 's1', eventType: 'tool_call', actor: 'agent', target: 'write_file', details: { file: 'b.ts' }, outcome: 'success', }); const rows = db.prepare('SELECT * FROM audit_logs ORDER BY id').all(); expect(rows.length).toBe(2); expect(rows[1].prev_hash).toBe(rows[0].current_hash); }); it('verifyChain 对完整链返回 valid', () => { const result = service.verifyChain(); expect(result.valid).toBe(true); expect(result.totalRecords).toBe(2); expect(result.tamperedId).toBeNull(); expect(result.verifiedRecords).toBe(2); }); it('篡改记录后 verifyChain 检测到 tampered', () => { // 需要先 drop 触发器才能 UPDATE(INSERT-ONLY 约束) db.exec('DROP TRIGGER audit_no_delete'); db.prepare('UPDATE audit_logs SET details = ? WHERE id = 1').run('{"tampered": true}'); db.exec(`CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_logs BEGIN SELECT RAISE(ABORT, 'Audit logs are INSERT-ONLY.'); END`); const result = service.verifyChain(); expect(result.valid).toBe(false); }); }); describe.skipIf(!dbAvailable)('AuditService — 链式哈希更多场景', () => { let dir: string; let db: any; let service: any; beforeEach(async () => { const { AuditService } = await import('../../services/audit.service'); dir = mkdtempSync(join(tmpdir(), 'metona-audit2-')); db = new Database(join(dir, 'test.db')); db.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, iteration INTEGER, event_type TEXT NOT NULL, actor TEXT NOT NULL DEFAULT 'system', target TEXT NOT NULL, details TEXT, outcome TEXT, duration_ms INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), prev_hash TEXT, current_hash TEXT ); `); service = new AuditService(() => db); }); afterEach(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('verifyChain 空表返回 valid 且 totalRecords=0', () => { const result = service.verifyChain(); expect(result.valid).toBe(true); expect(result.totalRecords).toBe(0); expect(result.verifiedRecords).toBe(0); }); it('verifyChain 检测断链位置:中间记录被篡改时 tamperedId 指向该行', () => { // 写入 3 条 for (let i = 0; i < 3; i++) { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: `t${i}`, outcome: 'error', }); } // 篡改第 2 条的 target db.prepare(`UPDATE audit_logs SET target = 'hacked' WHERE id = 2`).run(); const result = service.verifyChain(); expect(result.valid).toBe(false); expect(result.tamperedId).toBe(2); // 只有第 1 条被验证通过 expect(result.verifiedRecords).toBe(1); }); it('verifyChain 检测到首条被篡改(tamperedId=1)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't0', outcome: 'error', }); service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't1', outcome: 'error', }); db.prepare(`UPDATE audit_logs SET session_id = 'x' WHERE id = 1`).run(); const result = service.verifyChain(); expect(result.valid).toBe(false); expect(result.tamperedId).toBe(1); }); it('verifyChain 兼容旧算法(JSON.stringify 插入序生成的 legacy hash)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'legacy', outcome: 'error', details: { b: 1, a: 2 }, }); const row = db.prepare('SELECT * FROM audit_logs WHERE id = 1').get(); // 旧算法:JSON.stringify 按字段插入序(与 computeHash 传入顺序一致), // 对象 key 不排序 → 对含嵌套 details 的行与 stableStringify 结果不同。 const legacyContent = JSON.stringify({ prevHash: row.prev_hash, sessionId: row.session_id, iteration: row.iteration, eventType: row.event_type, actor: row.actor, target: row.target, details: row.details, // 已是序列化字符串 outcome: row.outcome, durationMs: row.duration_ms, createdAt: row.created_at, }); const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex'); // 用旧算法 hash 覆盖 current_hash,模拟 #36 之前落盘的旧数据 db.prepare('UPDATE audit_logs SET current_hash = ? WHERE id = 1').run(legacyHash); // 若 legacy 兼容分支未实现 → 判定 tampered;实现则 valid const result = service.verifyChain(); expect(result.valid).toBe(true); expect(result.tamperedId).toBeNull(); }); it('verifyChain 对新数据仍用 stableStringify 校验(旧算法 hash 无法通过新校验)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'modern', outcome: 'error', details: { b: 1, a: 2 }, }); const row = db.prepare('SELECT * FROM audit_logs WHERE id = 1').get(); const legacyContent = JSON.stringify({ prevHash: row.prev_hash, sessionId: row.session_id, iteration: row.iteration, eventType: row.event_type, actor: row.actor, target: row.target, details: row.details, outcome: row.outcome, durationMs: row.duration_ms, createdAt: row.created_at, }); const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex'); // 旧算法 hash 与 stable hash 不同(details 含多 key 对象时 key 顺序差异) expect(legacyHash).not.toBe(row.current_hash); // 但旧算法 hash 对当前行可被 legacy 分支接受 → verifyChain 仍 valid。 // 该分支的存在是设计意图(兼容旧数据),此处锁定"两算法结果不同"的区分度。 expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/); expect(service.verifyChain().valid).toBe(true); }); it('verifyChain 跳过 current_hash 为 NULL 的旧行(未回填哈希)', () => { db.prepare( `INSERT INTO audit_logs (session_id, event_type, actor, target, created_at, prev_hash, current_hash) VALUES ('s', 'error', 'agent', 'old-no-hash', 1, NULL, NULL)`, ).run(); service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'new-hash', outcome: 'error', }); const result = service.verifyChain(); expect(result.valid).toBe(true); // 新行验证通过 1 条 expect(result.verifiedRecords).toBe(1); }); it('resetChainCache 后首条 prev_hash 回到零哈希(P1-5 修复)', () => { // 写两条产生链 service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'a', outcome: 'error', }); service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'b', outcome: 'error', }); // 清空表(测试环境无 INSERT-ONLY 触发器) db.prepare('DELETE FROM audit_logs').run(); service.resetChainCache(); service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'first-after-reset', outcome: 'error', }); const row = db.prepare('SELECT prev_hash FROM audit_logs').get(); expect(row.prev_hash).toBe('0'.repeat(64)); expect(service.verifyChain().valid).toBe(true); }); it('未 resetChainCache 时清空表后新首条 prev_hash 引用旧哈希(P1-5 缺陷复现语义)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'old', outcome: 'error', }); const oldHash = ( db.prepare('SELECT current_hash FROM audit_logs').get() as { current_hash: string } ).current_hash; db.prepare('DELETE FROM audit_logs').run(); // 不调用 resetChainCache —— 缓存仍持有 oldHash service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'new', outcome: 'error', }); const row = db.prepare('SELECT prev_hash FROM audit_logs').get(); expect(row.prev_hash).toBe(oldHash); // 缺陷行为:引用已删除行的 hash // 验证链因此断裂 expect(service.verifyChain().valid).toBe(false); }); it('current_hash 是 64 位十六进制 SHA-256', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'hash-format', outcome: 'error', }); const row = db.prepare('SELECT current_hash FROM audit_logs').get(); expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/); }); it('相同 details 对象不同 key 顺序产生相同 hash(#36 stableStringify 契约)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'stable', details: { b: 1, a: 2 }, outcome: 'error', }); const row = db.prepare('SELECT current_hash FROM audit_logs').get(); // stableStringify 排序 key 后与显式构造的 {a:2,b:1} 一致 expect(row.current_hash).toMatch(/^[0-9a-f]{64}$/); // 再写一条同语义不同顺序的 → hash 与仅按顺序不同无法直接对比(created_at 参与), // 但确认不抛错且链验证通过 service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'stable2', details: { a: 2, b: 1 }, outcome: 'error', }); expect(service.verifyChain().valid).toBe(true); }); it('log 写入 details JSON 字符串(null 存 NULL)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'details-null', }); const row = db.prepare('SELECT details FROM audit_logs').get(); expect(row.details).toBeNull(); }); it('log 失败不抛错(写库异常时静默记录 WARN)', () => { // 关闭连接后写入 → prepare 抛错 → log 捕获 const closed = new Database(':memory:'); closed.close(); const broken = new service.constructor(() => closed); expect(() => broken.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'boom', }), ).not.toThrow(); }); it('logToolCall 写入 tool_call 事件且 long result 截断 1000 字符', () => { service.logToolCall({ sessionId: 's', iteration: 1, toolName: 'read_file', args: { path: '/a' }, outcome: 'success', result: 'r'.repeat(2000), durationMs: 5, }); const row = db.prepare('SELECT * FROM audit_logs').get(); expect(row.event_type).toBe('tool_call'); expect(row.target).toBe('read_file'); expect(row.iteration).toBe(1); const details = JSON.parse(row.details); expect(details.args).toEqual({ path: '/a' }); expect(details.result).toHaveLength(1000); expect(row.outcome).toBe('success'); expect(row.duration_ms).toBe(5); }); it('logLLMRequest 写入 llm_request 事件(target=provider/model)', () => { service.logLLMRequest({ sessionId: 's', iteration: 2, provider: 'deepseek', model: 'v4', tokenCount: 1000, }); const row = db.prepare('SELECT * FROM audit_logs').get(); expect(row.event_type).toBe('llm_request'); expect(row.target).toBe('deepseek/v4'); expect(JSON.parse(row.details)).toEqual({ tokenCount: 1000 }); }); it('logLLMResponse 写入 llm_response 事件含 tokenUsage 与 outcome=success', () => { service.logLLMResponse({ sessionId: 's', iteration: 2, provider: 'deepseek', model: 'v4', tokenUsage: { input: 10, output: 20, total: 30 }, durationMs: 42, }); const row = db.prepare('SELECT * FROM audit_logs').get(); expect(row.event_type).toBe('llm_response'); expect(row.outcome).toBe('success'); expect(row.duration_ms).toBe(42); expect(JSON.parse(row.details)).toEqual({ tokenUsage: { input: 10, output: 20, total: 30 } }); }); it('logSessionStart 写入 session_start 事件(actor=user)', () => { service.logSessionStart('sess-1'); const row = db.prepare('SELECT * FROM audit_logs').get(); expect(row.event_type).toBe('session_start'); expect(row.actor).toBe('user'); expect(row.target).toBe('session'); expect(row.session_id).toBe('sess-1'); }); it('logSessionEnd 写入 session_end 事件(details 含迭代/token/终止原因)', () => { service.logSessionEnd({ sessionId: 's', totalIterations: 5, totalTokens: 999, durationMs: 60000, terminationReason: 'completed', }); const row = db.prepare('SELECT * FROM audit_logs').get(); expect(row.event_type).toBe('session_end'); expect(row.outcome).toBe('success'); expect(JSON.parse(row.details)).toEqual({ totalIterations: 5, totalTokens: 999, terminationReason: 'completed', }); }); }); describe.skipIf(!dbAvailable)('AuditService — query 过滤矩阵', () => { let dir: string; let db: any; let service: any; beforeEach(async () => { const { AuditService } = await import('../../services/audit.service'); dir = mkdtempSync(join(tmpdir(), 'metona-audit-query-')); db = new Database(join(dir, 'test.db')); db.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, iteration INTEGER, event_type TEXT NOT NULL, actor TEXT NOT NULL DEFAULT 'system', target TEXT NOT NULL, details TEXT, outcome TEXT, duration_ms INTEGER, created_at INTEGER NOT NULL, prev_hash TEXT, current_hash TEXT ); `); service = new AuditService(() => db); // 预置 4 条不同 session/type/actor 的日志 const seed = [ { sessionId: 'sA', eventType: 'tool_call', actor: 'agent', target: 't1', created: 100 }, { sessionId: 'sA', eventType: 'error', actor: 'agent', target: 't2', created: 200 }, { sessionId: 'sB', eventType: 'tool_call', actor: 'user', target: 't3', created: 300 }, { sessionId: 'sB', eventType: 'permission_check', actor: 'system', target: 't4', created: 400, }, ]; for (const e of seed) { service.log({ sessionId: e.sessionId, eventType: e.eventType, actor: e.actor, target: e.target, }); db.prepare( 'UPDATE audit_logs SET created_at = ? WHERE id = (SELECT MAX(id) FROM audit_logs)', ).run(e.created); } }); afterEach(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('无过滤返回全部记录且按 created_at 倒序', () => { const rows = service.query(); expect(rows).toHaveLength(4); // created_at 倒序:400,300,200,100 expect(rows[0].target).toBe('t4'); expect(rows[3].target).toBe('t1'); }); it('sessionId 过滤只返回该会话', () => { const rows = service.query({ sessionId: 'sA' }); expect(rows).toHaveLength(2); expect(rows.every((r: any) => r.session_id === 'sA')).toBe(true); }); it('eventType 过滤只返回该事件类型', () => { const rows = service.query({ eventType: 'tool_call' }); expect(rows).toHaveLength(2); expect(rows.every((r: any) => r.event_type === 'tool_call')).toBe(true); }); it('sessionId + eventType 组合过滤', () => { const rows = service.query({ sessionId: 'sB', eventType: 'tool_call' }); expect(rows).toHaveLength(1); expect(rows[0].target).toBe('t3'); }); it('limit 限制返回条数(按时间倒序取前 N)', () => { const rows = service.query({ limit: 2 }); expect(rows).toHaveLength(2); expect(rows[0].target).toBe('t4'); expect(rows[1].target).toBe('t3'); }); it('limit 与过滤组合生效', () => { const rows = service.query({ sessionId: 'sB', limit: 1 }); expect(rows).toHaveLength(1); expect(rows[0].target).toBe('t4'); }); it('actor 字段随行返回(query 无 actor 过滤参数 —— 按 actor 由调用方筛选)', () => { const rows = service.query(); const actors = rows.map((r: any) => r.actor); expect(actors).toContain('user'); expect(actors).toContain('system'); expect(actors).toContain('agent'); }); it('不存在的 sessionId 过滤返回空数组', () => { expect(service.query({ sessionId: 'ghost' })).toHaveLength(0); }); }); describe.skipIf(!dbAvailable)('AuditService — 导出与脱敏', () => { let dir: string; let db: any; let service: any; beforeEach(async () => { const { AuditService } = await import('../../services/audit.service'); dir = mkdtempSync(join(tmpdir(), 'metona-audit-export2-')); db = new Database(join(dir, 'test.db')); db.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, iteration INTEGER, event_type TEXT NOT NULL, actor TEXT NOT NULL DEFAULT 'system', target TEXT NOT NULL, details TEXT, outcome TEXT, duration_ms INTEGER, created_at INTEGER NOT NULL, prev_hash TEXT, current_hash TEXT ); `); service = new AuditService(() => db); }); afterEach(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('exportJSONL 空表返回空串', () => { expect(service.exportJSONL()).toBe(''); }); it('exportJSONL 每行一条 JSON 且末尾换行', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't', outcome: 'error', }); const jsonl = service.exportJSONL(); expect(jsonl.endsWith('\n')).toBe(true); const lines = jsonl.trim().split('\n'); expect(lines).toHaveLength(1); const parsed = JSON.parse(lines[0]); expect(parsed.session_id).toBe('s'); expect(parsed.current_hash).toMatch(/^[0-9a-f]{64}$/); }); it('exportCSV 空表仅表头', () => { const csv = service.exportCSV(); const lines = csv.split('\n').filter((l: string) => l.length > 0); expect(lines).toHaveLength(1); expect(lines[0]).toContain('id,session_id,iteration,event_type'); }); it('CSV 转义:逗号字段被双引号包裹', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'a,b,c', outcome: 'error', }); const csv = service.exportCSV(); expect(csv).toContain('"a,b,c"'); }); it('CSV 转义:字段内双引号翻倍(RFC 4180)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'say "hi"', outcome: 'error', }); const csv = service.exportCSV(); expect(csv).toContain('"say ""hi"""'); }); it('CSV 转义:字段内换行保留且整字段双引号包裹(RFC 4180 多行字段)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'line1\nline2', outcome: 'error', }); const csv = service.exportCSV(); // 换行在双引号字段内 → 物理行 = 表头 + 记录(跨 2 行)+ 末尾空行(尾部 \n) expect(csv.split('\n')).toHaveLength(4); // 引号包裹的字段内保留字面换行 expect(csv).toContain('"line1\nline2"'); }); it('CSV 数值与空值字段不包裹', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'plain', outcome: 'error', durationMs: 123, }); const csv = service.exportCSV(); const row = csv.split('\n')[1]; // duration_ms 字段为纯数字不被引号包裹 expect(row).toContain(',123,'); // outcome 字段 plain 不带引号 expect(csv).toContain(',plain,'); }); it('exportJSONL 与 exportCSV 记录数一致', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't', outcome: 'error', }); service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'u', outcome: 'error', }); const jsonlCount = service.exportJSONL().trim().split('\n').length; const csvCount = service .exportCSV() .split('\n') .filter((l: string) => l.length > 0).length - 1; expect(jsonlCount).toBe(csvCount); }); it('CSV 表头字段序固定(导出 schema 稳定性)', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 't' }); const header = service.exportCSV().split('\n')[0]; expect(header).toBe( 'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash', ); }); it('JSONL 行含 prev_hash 与 current_hash 完整链字段', () => { service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'a' }); service.log({ sessionId: 's', eventType: 'error', actor: 'agent', target: 'b' }); const parsed = service .exportJSONL() .trim() .split('\n') .map((l: string) => JSON.parse(l)); expect(parsed[1].prev_hash).toBe(parsed[0].current_hash); }); });