Files
metona-ai-desktop/electron/ipc/__tests__/app-data.test.ts
T
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

871 lines
31 KiB
TypeScript
Raw 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.
/**
* IPC App / Data 域测试(v0.7.2 覆盖补齐)
*
* 锁定安全与数据完整性契约:
* 1. app:openExternal 协议白名单(M-12file:/smb:/javascript: 拒绝)
* 2. error:report 渲染层错误上报:超长字段截断 + 审计落库
* 3. audit:query 的 eventType 枚举校验与 limit 边界(M-45
* 4. data:export 的导出脱敏双保险(dataUrl 剥离 + 配置掩码 + 会话条数上限)
* 5. data:clear* 危险操作的事务语义与审计日志
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
const ipcMainHandleMock = vi.fn();
const ipcMainOnMock = vi.fn();
vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => ipcMainHandleMock(...args),
on: (...args: unknown[]) => ipcMainOnMock(...args),
},
shell: { openExternal: vi.fn(async () => undefined), showItemInFolder: vi.fn() },
dialog: { showOpenDialog: vi.fn(async () => ({ canceled: true, filePaths: [] })) },
BrowserWindow: { fromWebContents: vi.fn(() => null) },
app: {
getVersion: vi.fn(() => '0.7.2'),
getPath: vi.fn(() => '/tmp/userdata'),
relaunch: vi.fn(),
exit: vi.fn(),
},
}));
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { registerAppHandlers } from '../app';
import { registerDataHandlers } from '../data';
import type { IPCContext } from '../context';
function getHandler(channel: string): (...args: unknown[]) => Promise<unknown> {
const call = ipcMainHandleMock.mock.calls.find(([ch]) => ch === channel);
if (!call) throw new Error(`IPC handler not registered: ${channel}`);
return call[1] as (...args: unknown[]) => Promise<unknown>;
}
function getListener(channel: string): (...args: unknown[]) => void {
const call = ipcMainOnMock.mock.calls.find(([ch]) => ch === channel);
if (!call) throw new Error(`IPC listener not registered: ${channel}`);
return call[1] as (...args: unknown[]) => void;
}
beforeEach(() => {
ipcMainHandleMock.mockClear();
ipcMainOnMock.mockClear();
});
// ===== App 域 =====
describe('app:openExternal — 协议白名单(M-12', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it('http/https/mailto 放行', async () => {
registerAppHandlers(makeCtx());
const handler = getHandler('app:openExternal');
for (const url of ['https://example.com', 'http://example.com/x', 'mailto:a@b.com']) {
expect(await handler(null, url)).toMatchObject({ success: true });
}
});
it.each([
'file:///etc/passwd',
'smb://host/share',
'javascript:alert(1)',
'data:text/html,<script>',
'ftp://host/file',
])('%s → 拒绝', async (url) => {
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, url)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('not allowed');
});
it('非字符串/空 URL 拒绝', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:openExternal')(null, '')).toMatchObject({ success: false });
expect(await getHandler('app:openExternal')(null, 123)).toMatchObject({ success: false });
});
});
describe('audit 域 — 校验矩阵(M-45', () => {
function makeCtx(): { ctx: IPCContext; query: Mock } {
const query = vi.fn(() => []);
return {
ctx: {
auditService: {
query,
log: vi.fn(),
verifyChain: vi.fn(() => ({
valid: true,
totalRecords: 0,
verifiedRecords: 0,
tamperedId: null,
})),
},
} as unknown as IPCContext,
query,
};
}
it('audit:query 非法 eventType 拒绝(枚举白名单)', async () => {
const { ctx } = makeCtx();
registerAppHandlers(ctx);
const result = (await getHandler('audit:query')(null, { eventType: 'DROP TABLE' })) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid eventType');
});
it('audit:query limit 边界(1-1000 之外拒绝,防超大查询)', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
const handler = getHandler('audit:query');
expect(await handler(null, { limit: 0 })).toMatchObject({ success: false });
expect(await handler(null, { limit: 1001 })).toMatchObject({ success: false });
expect(await handler(null, { limit: Number.NaN })).toMatchObject({ success: false });
await handler(null, { limit: 500 });
expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 500 }));
});
it('audit:verifyChain 透传结果', async () => {
const { ctx } = makeCtx();
registerAppHandlers(ctx);
const result = (await getHandler('audit:verifyChain')(null)) as {
success: boolean;
valid: boolean;
};
expect(result).toMatchObject({ success: true, valid: true });
});
});
describe('searxng:testConnection — 连接测试', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it('非法 URL 直接拒绝(不发起网络请求)', async () => {
registerAppHandlers(makeCtx());
const result = (await getHandler('searxng:testConnection')(null, 'ftp://x', '', '')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('http');
});
it('Basic 认证 → Authorization 头为 Base64 编码', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (..._args: unknown[]) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'user:pass', 'basic');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`);
vi.unstubAllGlobals();
});
it('网络异常 → success:false + 错误信息', async () => {
registerAppHandlers(makeCtx());
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('ECONNREFUSED');
}),
);
const result = (await getHandler('searxng:testConnection')(
null,
'https://sx.local',
'',
'',
)) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('ECONNREFUSED');
vi.unstubAllGlobals();
});
});
describe('error:report — 渲染层错误上报通道(P0-3)', () => {
it('超长字段截断 + 审计落库(TOOL 层)', async () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, {
type: 'render-crash',
error: 'e'.repeat(2000),
stack: 's'.repeat(10000),
componentStack: 'c'.repeat(8000),
timestamp: Date.now(),
});
expect(log).toHaveBeenCalledTimes(1);
const entry = log.mock.calls[0][0] as { eventType: string; details: Record<string, unknown> };
expect(entry.eventType).toBe('error');
expect((entry.details.error as string).length).toBe(1000);
expect((entry.details.stack as string).length).toBe(4000);
expect((entry.details.componentStack as string).length).toBe(4000);
});
it('非法载荷安全忽略(单向通道不抛错)', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
expect(() => listener(null, null)).not.toThrow();
expect(() => listener(null, 'plain string')).not.toThrow();
expect(log).not.toHaveBeenCalled();
});
});
// ===== Data 域 =====
describe('data:export — 导出脱敏双保险', () => {
function makeCtx(
messages: unknown[],
sessions?: unknown[],
): { ctx: IPCContext; sessionService: Record<string, Mock> } {
const sessionService = {
getMessages: vi.fn(() => messages),
list: vi.fn(() => sessions ?? []),
};
const configService = {
getAll: vi.fn(() => ({ 'llm.apiKey': 'sk-export-raw', 'ui.theme': 'dark' })),
};
return {
ctx: { sessionService, configService } as unknown as IPCContext,
sessionService,
};
}
it('单会话导出:toolResult.dataUrl 被剥离(view_image base64 防泄漏)', async () => {
const bigDataUrl = `data:image/png;base64,${'A'.repeat(100)}`;
const messages = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: { path: 'x.png', dataUrl: bigDataUrl } },
},
{
id: 'm2',
role: 'user',
attachments: [{ name: 'pic.png', preview: 'data:image/jpeg;base64,BBBB' }],
},
];
const { ctx } = makeCtx(messages);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
success: boolean;
data: unknown[];
};
expect(result.success).toBe(true);
const toolMsg = result.data[0] as { toolResult: { result: Record<string, unknown> } };
expect(toolMsg.toolResult.result.dataUrl).toBeUndefined();
expect(toolMsg.toolResult.result.path).toBe('x.png'); // 小字段保留
expect(toolMsg.toolResult.result._displayNote).toBeDefined();
const userMsg = result.data[1] as { attachments: Array<Record<string, unknown>> };
expect(userMsg.attachments[0].preview).toBeUndefined(); // preview 剥离
expect(userMsg.attachments[0].name).toBe('pic.png');
});
it('全量导出:配置敏感值脱敏(S-1),普通字段原样', async () => {
const { ctx } = makeCtx([], [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
success: boolean;
data: { config: Record<string, unknown>; sessions: unknown[] };
};
expect(result.data.config['llm.apiKey']).toBe('***-raw'); // maskSensitive:后 4 位保留
expect(result.data.config['ui.theme']).toBe('dark'); // 非敏感原样
expect(JSON.stringify(result.data.config)).not.toContain('sk-export-raw');
});
it('导出失败 → success:false', async () => {
const { ctx, sessionService } = makeCtx([]);
sessionService.getMessages.mockImplementation(() => {
throw new Error('db gone');
});
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe('db gone');
});
});
describe('data:clear* — 危险操作事务语义', () => {
function makeDbMock(): { prepare: Mock; exec: Mock } {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 2 })),
all: vi.fn(() => []),
}));
const exec = vi.fn();
return { prepare, exec };
}
it('clearSessionsBEGIN/DELETE/COMMIT 事务包裹 + 删除计数', async () => {
const { prepare, exec } = makeDbMock();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearSessions')(null)) as {
success: boolean;
deletedSessions: number;
};
expect(result).toMatchObject({ success: true, deletedSessions: 2, deletedMessages: 2 });
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toEqual(expect.arrayContaining(['BEGIN', 'COMMIT']));
});
it('clearSessions 失败 → ROLLBACK(消息/会话表一致性)', async () => {
// DELETE 走 db.exec(非 prepare)—— 失败注入点在 exec 的 sessions 删除
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM sessions')) {
throw new Error('foreign key constraint');
}
});
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearSessions')(null)) as { success: boolean };
expect(result.success).toBe(false);
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearMemories 三张记忆表事务包裹(M-41 一致性)', async () => {
const { prepare, exec } = makeDbMock();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as {
success: boolean;
deletedEpisodic: number;
};
expect(result.success).toBe(true);
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toEqual(expect.arrayContaining(['BEGIN', 'COMMIT']));
});
it('clearAuditLogs:先删防篡改触发器再清表,最后重建触发器(INSERT-ONLY 契约)', async () => {
const { prepare, exec } = makeDbMock();
const resetChainCache = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
// v0.7.4 P1-5: 清空后重置链式哈希缓存(防 verifyChain 误报 TAMPERED
auditService: { resetChainCache },
} as unknown as IPCContext);
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
expect(result.success).toBe(true);
expect(resetChainCache).toHaveBeenCalled();
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls[0]).toBe('BEGIN');
expect(execCalls.some((c) => c.includes('DROP TRIGGER IF EXISTS audit_no_delete'))).toBe(true);
expect(execCalls.some((c) => c.includes('CREATE TRIGGER audit_no_delete BEFORE DELETE'))).toBe(
true,
);
expect(execCalls[execCalls.length - 1]).toBe('COMMIT');
});
it('clearMemories 返回三张表各自的删除计数', async () => {
const { prepare, exec } = makeDbMock();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as {
deletedEpisodic: number;
deletedSemantic: number;
deletedWorking: number;
};
expect(result).toEqual({
success: true,
deletedEpisodic: 2,
deletedSemantic: 2,
deletedWorking: 2,
});
});
it('clearMemories 中途失败 → ROLLBACK(三表一致性)', async () => {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM working_memories')) {
throw new Error('db error');
}
});
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearMemories')(null)) as { success: boolean };
expect(result.success).toBe(false);
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearAuditLogs 失败 → ROLLBACK 且不重置链缓存', async () => {
const prepare = vi.fn(() => ({
run: vi.fn(),
get: vi.fn(() => ({ c: 1 })),
all: vi.fn(() => []),
}));
const exec = vi.fn((sql: string) => {
if (sql.includes('DELETE FROM audit_logs')) throw new Error('locked');
});
const resetChainCache = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
auditService: { resetChainCache },
} as unknown as IPCContext);
const result = (await getHandler('data:clearAuditLogs')(null)) as { success: boolean };
expect(result.success).toBe(false);
expect(resetChainCache).not.toHaveBeenCalled();
const execCalls = exec.mock.calls.map((c) => c[0] as string);
expect(execCalls).toContain('ROLLBACK');
});
it('clearSessions 删除计数准确(session/message 各行)', async () => {
const prepare = vi.fn((sql: string) => ({
run: vi.fn(),
get: vi.fn(() => (sql.includes('FROM messages') ? { c: 5 } : { c: 3 })),
all: vi.fn(() => []),
}));
const exec = vi.fn();
registerDataHandlers({
sessionService: { getDB: () => ({ prepare, exec }) },
} as unknown as IPCContext);
const result = (await getHandler('data:clearSessions')(null)) as {
deletedSessions: number;
deletedMessages: number;
};
expect(result).toEqual({ success: true, deletedSessions: 3, deletedMessages: 5 });
});
});
// ===== 追加:app 域补充 =====
describe('app:openExternal — 协议白名单扩展(M-12', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it.each([
'chrome://settings',
'about:blank',
'ws://host',
'ftp://host',
'file:///etc',
'smb://x',
'javascript:void(0)',
])('%s → 协议不在白名单拒绝', async (url) => {
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, url)) as { success: boolean };
expect(result.success).toBe(false);
});
it('大小写协议(HTTPS)→ 解析为 https: 放行', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:openExternal')(null, 'HTTPS://example.com')).toMatchObject({
success: true,
});
});
it('shell.openExternal 抛错 → 返回失败', async () => {
const shellMock = (await import('electron')).shell as unknown as {
openExternal: Mock;
};
shellMock.openExternal.mockRejectedValueOnce(new Error('no browser'));
registerAppHandlers(makeCtx());
const result = (await getHandler('app:openExternal')(null, 'https://example.com')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toBe('no browser');
});
it('app:showItemInFolder 校验 path 类型', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:showItemInFolder')(null, '')).toMatchObject({ success: false });
expect(await getHandler('app:showItemInFolder')(null, 42)).toMatchObject({ success: false });
expect(await getHandler('app:showItemInFolder')(null, '/tmp/x')).toMatchObject({
success: true,
});
});
it('app:getVersion / app:getAppDataPath 透传', async () => {
registerAppHandlers(makeCtx());
expect(await getHandler('app:getVersion')(null)).toBe('0.7.2');
expect(await getHandler('app:getAppDataPath')(null)).toBe('/tmp/userdata');
});
});
describe('searxng:testConnection — 高危 URL 拒绝(v0.7.4 P2-9', () => {
function makeCtx(): IPCContext {
return { configService: { get: vi.fn(() => null) } } as unknown as IPCContext;
}
it('云元数据 169.254.169.254 拒绝', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://169.254.169.254/latest/meta-data',
'',
'',
)) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it('metadata.google.internal 拒绝', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://metadata.google.internal/',
'',
'',
)) as { success: boolean };
expect(result.success).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it('本地回环 SearXNG 实例放行(自建实例合法)', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
const result = (await getHandler('searxng:testConnection')(
null,
'http://127.0.0.1:8080',
'',
'',
)) as { success: boolean; statusCode?: number };
expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
// 测试 URL 拼接 /search?q=test
expect(fetchSpy.mock.calls[0][0]).toContain('/search?q=test');
vi.unstubAllGlobals();
});
it('非 2xx 响应 → 返回 statusCode + HTTP 错误信息', async () => {
registerAppHandlers(makeCtx());
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, statusText: 'Internal Server Error' })),
);
const result = (await getHandler('searxng:testConnection')(
null,
'https://sx.local',
'',
'',
)) as { success: boolean; statusCode?: number; error?: string };
expect(result.success).toBe(false);
expect(result.statusCode).toBe(500);
expect(result.error).toContain('HTTP 500');
vi.unstubAllGlobals();
});
it('无认证信息时不携带 Authorization 头', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', '', '');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers).toEqual({});
vi.unstubAllGlobals();
});
it('Bearer 认证 → Authorization: Bearer <key>', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'tok-abc', 'bearer');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBe('Bearer tok-abc');
vi.unstubAllGlobals();
});
it('未知 authType → 不附加认证头(仅接受 bearer/basic', async () => {
registerAppHandlers(makeCtx());
const fetchSpy = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
ok: true,
status: 200,
statusText: 'OK',
}));
vi.stubGlobal('fetch', fetchSpy);
await getHandler('searxng:testConnection')(null, 'https://sx.local', 'secret', 'oauth');
const headers = (fetchSpy.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(headers['Authorization']).toBeUndefined();
vi.unstubAllGlobals();
});
});
describe('error:report — 截断与审计补充', () => {
it('type 字段截断到 100 字符', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, { type: 'T'.repeat(300), error: 'e', stack: '', timestamp: Date.now() });
const entry = log.mock.calls[0][0] as { details: { type: string } };
expect(entry.details.type.length).toBe(100);
});
it('日志处理自身抛错不向外抛出(单向通道安全)', () => {
const log = vi.fn(() => {
throw new Error('audit db down');
});
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
expect(() => listener(null, { type: 'x', error: 'e' })).not.toThrow();
});
it('数组载荷通过 typeof 守卫被记录(仅 null/原始类型被忽略;数组字段为 undefined', () => {
const log = vi.fn();
registerAppHandlers({ auditService: { log } } as unknown as IPCContext);
const listener = getListener('error:report');
listener(null, [1, 2, 3]);
// 源码守卫 `typeof payload !== 'object'` 对数组放行 —— 数组被当作对象记录(字段 undefined)
expect(log).toHaveBeenCalledTimes(1);
});
});
describe('audit 域 — 补充校验', () => {
function makeCtx(): { ctx: IPCContext; query: Mock } {
const query = vi.fn(() => []);
return {
ctx: {
auditService: {
query,
log: vi.fn(),
verifyChain: vi.fn(() => ({
valid: true,
totalRecords: 0,
verifiedRecords: 0,
tamperedId: null,
})),
},
} as unknown as IPCContext,
query,
};
}
it('audit:query sessionId 过滤透传;空 sessionId 被忽略', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
const handler = getHandler('audit:query');
await handler(null, { sessionId: 's1', limit: 10 });
expect(query).toHaveBeenCalledWith({ sessionId: 's1', limit: 10 });
await handler(null, { sessionId: '' });
expect(query).toHaveBeenLastCalledWith({});
});
it('audit:query 合法 eventType 白名单透传', async () => {
const { ctx, query } = makeCtx();
registerAppHandlers(ctx);
await getHandler('audit:query')(null, { eventType: 'tool_call' });
expect(query).toHaveBeenCalledWith({ eventType: 'tool_call' });
});
it('audit:export 默认 jsonlcsv 显式选择', async () => {
const exportJSONL = vi.fn(() => '{"a":1}\n{"b":2}\n');
const exportCSV = vi.fn(() => 'col\nval\n');
registerAppHandlers({
auditService: { exportJSONL, exportCSV, log: vi.fn() },
} as unknown as IPCContext);
const handler = getHandler('audit:export');
const jsonl = (await handler(null)) as { format: string; recordCount: number };
expect(jsonl.format).toBe('jsonl');
expect(jsonl.recordCount).toBe(2);
const csv = (await handler(null, 'csv')) as { format: string; recordCount: number };
expect(csv.format).toBe('csv');
expect(csv.recordCount).toBe(1); // 表头不计入
});
it('audit:export 任意 format 回退 jsonl(非 csv 一律 jsonl', async () => {
const exportJSONL = vi.fn(() => 'x\n');
registerAppHandlers({
auditService: { exportJSONL, exportCSV: vi.fn(), log: vi.fn() },
} as unknown as IPCContext);
const r = (await getHandler('audit:export')(null, 'xml')) as { format: string };
expect(r.format).toBe('jsonl');
});
it('audit:export 空内容 → recordCount 0', async () => {
registerAppHandlers({
auditService: { exportJSONL: vi.fn(() => ''), exportCSV: vi.fn(() => ''), log: vi.fn() },
} as unknown as IPCContext);
const r = (await getHandler('audit:export')(null)) as { recordCount: number };
expect(r.recordCount).toBe(0);
});
it('audit:verifyChain 失败路径返回错误', async () => {
registerAppHandlers({
auditService: {
verifyChain: vi.fn(() => {
throw new Error('chain corrupted');
}),
log: vi.fn(),
},
} as unknown as IPCContext);
const r = (await getHandler('audit:verifyChain')(null)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(r.error).toBe('chain corrupted');
});
});
// ===== 追加:data:export 脱敏 =====
describe('data:export — 补充脱敏', () => {
function makeCtx(messages: unknown[], sessions?: unknown[]) {
const sessionService = {
getMessages: vi.fn(() => messages),
list: vi.fn(() => sessions ?? []),
};
const configService = {
getAll: vi.fn(() => ({ 'llm.apiKey': 'sk-raw-1234', 'ui.theme': 'dark' })),
};
return { ctx: { sessionService, configService } as unknown as IPCContext, sessionService };
}
it('全量导出:非工具消息原样保留(无 dataUrl 不动)', async () => {
const msgs = [
{ id: 'm1', role: 'user', content: 'hello', timestamp: Date.now() },
{ id: 'm2', role: 'assistant', content: 'hi back', timestamp: Date.now() },
];
const { ctx } = makeCtx(msgs, [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { sessions: Array<{ messages: unknown[]; truncated: boolean }> };
};
const session = result.data.sessions[0];
expect(session.messages).toEqual(msgs);
expect(session.truncated).toBe(false);
});
it('全量导出:条数达到上限时标记 truncated=true(防 Blob 序列化 OOM', async () => {
const { ctx, sessionService } = makeCtx([], [{ id: 's1', title: 't' }]);
// 构造恰好 2000 条消息(达到上限 → truncated=true
const many = Array.from({ length: 2000 }, (_, i) => ({
id: `m${i}`,
role: 'user',
content: 'x',
}));
sessionService.getMessages.mockImplementation(() => many);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { sessions: Array<{ truncated: boolean }> };
};
expect(result.data.sessions[0].truncated).toBe(true);
});
it('全量导出配置脱敏不包含明文 API Key 子串', async () => {
const { ctx } = makeCtx([], [{ id: 's1', title: 't' }]);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, undefined)) as {
data: { config: Record<string, unknown> };
};
expect(JSON.stringify(result.data.config)).not.toContain('sk-raw-1234');
});
it('单会话导出不返回 truncated 标记(仅全量导出带)', async () => {
const msgs = [{ id: 'm1', role: 'user', content: 'x' }];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
success: boolean;
data: unknown[];
};
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
});
it('toolResult.result 为字符串时不剥离(dataUrl 剥离仅针对对象)', async () => {
const msgs = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: 'plain string result' },
},
];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
data: Array<{ toolResult: { result: string } }>;
};
expect(result.data[0].toolResult.result).toBe('plain string result');
});
it('toolResult.result 为数组(非对象)不剥离', async () => {
const msgs = [
{
id: 'm1',
role: 'tool',
toolResult: { toolCallId: 'tc_1', result: [{ path: 'a.png', dataUrl: 'x' }] },
},
];
const { ctx } = makeCtx(msgs);
registerDataHandlers(ctx);
const result = (await getHandler('data:export')(null, 's1')) as {
data: Array<{ toolResult: { result: Array<{ dataUrl: string }> } }>;
};
expect(result.data[0].toolResult.result).toHaveLength(1);
expect(result.data[0].toolResult.result[0].dataUrl).toBe('x');
});
});