Files
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
2026-08-30 19:19:07 +08:00

784 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 触发器才能 UPDATEINSERT-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);
});
});