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)
871 lines
31 KiB
TypeScript
871 lines
31 KiB
TypeScript
/**
|
||
* IPC App / Data 域测试(v0.7.2 覆盖补齐)
|
||
*
|
||
* 锁定安全与数据完整性契约:
|
||
* 1. app:openExternal 协议白名单(M-12:file:/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('clearSessions:BEGIN/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 默认 jsonl;csv 显式选择', 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');
|
||
});
|
||
});
|