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)
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* AuditService 单元测试(P1-14 测试基线)
|
||||
* 覆盖:链式哈希生成与验证、篡改检测
|
||||
* AuditService 单元测试(P1-14 测试基线扩充)
|
||||
* 覆盖:链式哈希生成与验证、篡改检测、resetChainCache、query 过滤矩阵、
|
||||
* CSV/JSONL 导出转义、logToolCall/logLLMRequest/logSessionEnd 等便捷方法。
|
||||
*
|
||||
* 注意:better-sqlite3 为原生模块——若本机安装的构建与系统 Node ABI 不匹配,
|
||||
* 测试自动跳过(skipIf),避免误报失败。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
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 不匹配)自动跳过整个套件
|
||||
@@ -32,14 +34,12 @@ log.transports.file.level = false;
|
||||
|
||||
describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
let dir: string;
|
||||
// eslint-disable 是失效指令(测试文件 overrides 已关闭 no-explicit-any),已移除
|
||||
let db: any;
|
||||
let service: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { AuditService } = await import('../../services/audit.service');
|
||||
dir = mkdtempSync(join(tmpdir(), 'metona-audit-'));
|
||||
// 手动建库(绕过 DatabaseService 的 electron 依赖)
|
||||
db = new Database(join(dir, 'test.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
@@ -65,7 +65,11 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { db?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -103,6 +107,8 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
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', () => {
|
||||
@@ -115,3 +121,663 @@ describe.skipIf(!dbAvailable)('AuditService 链式哈希', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user