Files
metona-ai-desktop/electron/ipc/__tests__/sessions-tools-config.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

808 lines
30 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 域 Handler 测试(v0.7.2 P4-14 覆盖补齐)
*
* sessions / tools / config 三个域此前零测试。本文件锁定三类契约:
* 1. 参数校验矩阵(M-34/M-35/M-42 系列修复的回归防线)—— 非法输入被拒绝
* 且不触达 service 层
* 2. v0.7.2 A1 语义修正 —— sessions:clearMessages 的成功判定是"操作完成"
* 而非"有行被删除"(空会话清空同样 success:true
* 3. tools:toggle / setAutoExecute 的未知工具拒绝(防配置 key 污染)与
* config:set/setBatch 的类型校验、敏感值脱敏审计
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// ===== Mock electronipcMain =====
const ipcMainHandleMock = vi.fn();
const ipcMainOnMock = vi.fn();
vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => ipcMainHandleMock(...args),
on: (...args: unknown[]) => ipcMainOnMock(...args),
},
}));
// ===== Mock broadcastipc/contextshared.ts 的 config:changed 广播) =====
const broadcastMock = vi.fn();
vi.mock('../context', () => ({
broadcast: (...args: unknown[]) => broadcastMock(...args),
}));
import { registerSessionHandlers } from '../sessions';
import { registerToolHandlers } from '../tools';
import { registerConfigHandlers } from '../config';
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}`);
// ipcMain.on 处理器签名 (event, ...args) —— 返回宽松签名以便按双参调用
return call[1] as (...args: unknown[]) => void;
}
beforeEach(() => {
ipcMainHandleMock.mockClear();
ipcMainOnMock.mockClear();
broadcastMock.mockClear();
});
// ===== sessions 域 =====
describe('sessions 域 — 参数校验矩阵', () => {
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
sessionService: {
list: vi.fn(() => []),
create: vi.fn(),
rename: vi.fn(() => true),
delete: vi.fn(() => true),
getMessages: vi.fn(() => []),
pin: vi.fn(() => true),
archive: vi.fn(() => true),
// v0.7.3 P1-3: sessions:deleteMessage 死通道已删除,service 方法一并移除
clearMessages: vi.fn(),
truncateMessagesAfter: vi.fn(() => true),
searchMessages: vi.fn(() => []),
saveTraceData: vi.fn(),
getTraceData: vi.fn(() => null),
},
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('rename / delete / pin / archive 对非法 sessionId 拒绝且不触达 service', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
for (const bad of [null, undefined, 123, '', 'x'.repeat(201)]) {
expect(await getHandler('sessions:rename')(null, bad, 't')).toMatchObject({ success: false });
expect(await getHandler('sessions:delete')(null, bad)).toMatchObject({ success: false });
expect(await getHandler('sessions:pin')(null, bad, true)).toMatchObject({ success: false });
expect(await getHandler('sessions:archive')(null, bad, true)).toMatchObject({
success: false,
});
}
expect(svc.rename).not.toHaveBeenCalled();
expect(svc.delete).not.toHaveBeenCalled();
expect(svc.pin).not.toHaveBeenCalled();
expect(svc.archive).not.toHaveBeenCalled();
});
it('rename 拒绝空 titlepin/archive 拒绝非布尔', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
expect(await getHandler('sessions:rename')(null, 's1', ' ')).toMatchObject({ success: false });
expect(await getHandler('sessions:pin')(null, 's1', 'yes')).toMatchObject({ success: false });
expect(await getHandler('sessions:archive')(null, 's1', 1)).toMatchObject({ success: false });
expect(svc.rename).not.toHaveBeenCalled();
expect(svc.pin).not.toHaveBeenCalled();
expect(svc.archive).not.toHaveBeenCalled();
});
it('getMessages 对非法 sessionId 返回空数组;合法调用透传', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]);
await getHandler('sessions:getMessages')(null, 's1');
expect(svc.getMessages).toHaveBeenCalledWith('s1');
});
it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
expect(await getHandler('sessions:saveTrace')(null, 's1', null)).toMatchObject({
success: false,
});
expect(await getHandler('sessions:saveTrace')(null, 's1', { tokenUsage: {} })).toMatchObject({
success: false,
});
expect(await getHandler('sessions:saveTrace')(null, 's1', { traceSteps: 'x' })).toMatchObject({
success: false,
});
expect(svc.saveTraceData).not.toHaveBeenCalled();
await getHandler('sessions:saveTrace')(null, 's1', {
traceSteps: [],
tokenUsage: { totalTokens: 0 },
});
expect(svc.saveTraceData).toHaveBeenCalledWith('s1', {
traceSteps: [],
tokenUsage: { totalTokens: 0 },
});
});
});
describe('sessions:clearMessages — v0.7.2 A1 语义修正', () => {
function makeCtx(): IPCContext {
return {
sessionService: { clearMessages: vi.fn() },
} as unknown as IPCContext;
}
it('操作完成即 success:true(空会话 / 0 行删除同样是成功)', async () => {
registerSessionHandlers(makeCtx());
const result = await getHandler('sessions:clearMessages')(null, 's1');
expect(result).toEqual({ success: true });
});
it('service 抛错 → success:false + 错误信息', async () => {
const ctx = {
sessionService: {
clearMessages: vi.fn(() => {
throw new Error('db locked');
}),
},
} as unknown as IPCContext;
registerSessionHandlers(ctx);
const result = await getHandler('sessions:clearMessages')(null, 's1');
expect(result).toEqual({ success: false, error: 'db locked' });
});
it('非法 sessionId 拒绝且不触达 service', async () => {
const ctx = makeCtx();
registerSessionHandlers(ctx);
const result = await getHandler('sessions:clearMessages')(null, '');
expect(result).toMatchObject({ success: false });
expect(
(ctx.sessionService as unknown as { clearMessages: Mock }).clearMessages,
).not.toHaveBeenCalled();
});
});
// ===== tools 域 =====
describe('tools 域 — 工具开关与确认链路', () => {
const TOOL_A = {
name: 'write_file',
description: 'd',
category: 'filesystem',
riskLevel: 'medium',
requiresPermission: true,
enabled: true,
};
const TOOL_B = { ...TOOL_A, name: 'run_command', riskLevel: 'high' };
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
toolRegistry: {
listAllTools: vi.fn(() => [TOOL_A, TOOL_B]),
listTools: vi.fn(() => [TOOL_A, TOOL_B]),
setToolEnabled: vi.fn(),
},
confirmationHook: {
resolveConfirmation: vi.fn(),
resolveConfirmationsBatch: vi.fn(() => ['tc_1']),
getPendingConfirmations: vi.fn(() => []),
getRememberedDenials: vi.fn(() => []),
resetRememberedDenial: vi.fn(() => true),
setAutoExecute: vi.fn(),
getAutoExecuteList: vi.fn(() => []),
},
agentEngineManager: { setToolsAll: vi.fn() },
configService: { set: vi.fn() },
toolsReadyRef: { ready: true, toolCount: 2 },
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('tools:list 透传字段', async () => {
const { ctx } = makeCtx();
registerToolHandlers(ctx);
const list = (await getHandler('tools:list')(null)) as Array<Record<string, unknown>>;
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({ name: 'write_file', riskLevel: 'medium', enabled: true });
});
it('tools:toggle 未知工具拒绝(防配置 key 污染)且不触达 registry/config', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const r = await getHandler('tools:toggle')(null, 'not_a_tool', true);
expect(r).toMatchObject({ success: false });
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
expect((raw.toolRegistry as { setToolEnabled: Mock }).setToolEnabled).not.toHaveBeenCalled();
});
it('tools:toggle 合法路径:持久化配置 + registry + 引擎工具列表三处同步', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const r = await getHandler('tools:toggle')(null, 'write_file', false);
expect(r).toEqual({ success: true });
expect((raw.configService as { set: Mock }).set).toHaveBeenCalledWith(
'tools.write_file.enabled',
false,
);
expect((raw.toolRegistry as { setToolEnabled: Mock }).setToolEnabled).toHaveBeenCalledWith(
'write_file',
false,
);
expect((raw.agentEngineManager as { setToolsAll: Mock }).setToolsAll).toHaveBeenCalled();
});
it('tools:isReady 读取共享就绪引用', async () => {
const { ctx } = makeCtx();
registerToolHandlers(ctx);
expect(await getHandler('tools:isReady')(null)).toEqual({ ready: true, toolCount: 2 });
});
it('tool:confirmationResponse 校验结构(无效载荷被忽略)', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const listener = getListener('tool:confirmationResponse');
// 处理器签名 (event, data) —— 首参为 event,载荷在第二参
listener(null, null);
listener(null, { approved: true });
listener(null, { toolCallId: 'tc_1', approved: 'yes' });
const hook = (raw.confirmationHook as { resolveConfirmation: Mock }).resolveConfirmation;
expect(hook).not.toHaveBeenCalled();
listener(null, { toolCallId: 'tc_1', approved: true, remember: true, autoExecute: false });
expect(hook).toHaveBeenCalledWith('tc_1', true, true, false);
});
it('tool:confirmationResponseBatch 校验 toolCallIds 数组', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const listener = getListener('tool:confirmationResponseBatch');
const hook = (raw.confirmationHook as { resolveConfirmationsBatch: Mock })
.resolveConfirmationsBatch;
listener(null, { toolCallIds: 'tc_1', approved: true });
listener(null, { toolCallIds: [], approved: true });
listener(null, { toolCallIds: ['tc_1', 42], approved: true });
expect(hook).not.toHaveBeenCalled();
listener(null, { toolCallIds: ['tc_1', 'tc_2'], approved: false, remember: true });
expect(hook).toHaveBeenCalledWith(['tc_1', 'tc_2'], false, true, false);
});
it('tool:setAutoExecute 未知工具拒绝;拒绝记忆接口透传 sessionId', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
expect(await getHandler('tool:setAutoExecute')(null, 'nope', true)).toMatchObject({
success: false,
});
expect(await getHandler('tool:setAutoExecute')(null, 'write_file', 'yes')).toMatchObject({
success: false,
});
expect(await getHandler('tool:setAutoExecute')(null, 'write_file', true)).toEqual({
success: true,
});
await getHandler('tool:getRememberedDenials')(null, 'sess-9');
expect(
(raw.confirmationHook as { getRememberedDenials: Mock }).getRememberedDenials,
).toHaveBeenCalledWith('sess-9');
});
});
// ===== config 域 =====
describe('config 域 — 配置写入与审计', () => {
function makeCtx(overrides: Record<string, unknown> = {}): {
ctx: IPCContext;
raw: Record<string, unknown>;
} {
const raw = {
configService: {
get: vi.fn((key: string) => (key === 'llm.provider' ? 'deepseek' : null)),
set: vi.fn(),
// v0.7.4 P3-6: setBatch 接线(批量保存单事务 + 全局层一次落盘)
setBatch: vi.fn(),
},
auditService: { log: vi.fn() },
agentEngineManager: { updateConfigAll: vi.fn() },
orchestrator: { updateDefaultConfig: vi.fn() },
confirmationHook: { setConfirmationTimeout: vi.fn() },
reloadAdapter: vi.fn(() => true),
...overrides,
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('config:get / config:set 类型校验矩阵', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
await getHandler('config:get')(null, 'ui.theme');
expect((raw.configService as { get: Mock }).get).toHaveBeenCalledWith('ui.theme');
expect(await getHandler('config:set')(null, '', 'x')).toMatchObject({ success: false });
expect(await getHandler('config:set')(null, 'k', { nested: true })).toMatchObject({
success: false,
});
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
});
it('config:set 敏感值脱敏入审计(明文 API Key 不落审计日志)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
await getHandler('config:set')(null, 'llm.apiKey', 'sk-test-1234567890');
const auditCall = (raw.auditService as { log: Mock }).log.mock.calls[0][0] as {
target: string;
details: { value: unknown };
};
expect(auditCall.target).toBe('llm.apiKey');
expect(auditCall.details.value).toBe('***7890');
});
it('config:setBatch 非法载荷整体拒绝(非数组 / 空 / 坏 key / 坏值类型)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
for (const bad of [null, [], [{ key: '', value: 1 }], [{ key: 'k', value: {} }]]) {
expect(await getHandler('config:setBatch')(null, bad)).toMatchObject({ success: false });
}
// v0.7.4 P3-6: 非法输入不触发任何写入(setBatch 不被调用)
expect((raw.configService as { setBatch: Mock }).setBatch).not.toHaveBeenCalled();
});
it('config:setBatch 合法路径:批量写入 + 审计 + 广播;Provider 变更先清 apiKey', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:setBatch')(null, [
{ key: 'ui.theme', value: 'light' },
{ key: 'llm.provider', value: 'ollama' },
{ key: 'llm.model', value: 'qwen3:8b' },
]);
expect(r).toEqual({ success: true });
// v0.7.4 P3-6: 批量写入收敛为 configService.setBatch 单次调用(单事务 + 全局层一次落盘)
const setBatch = (raw.configService as { setBatch: Mock }).setBatch;
expect(setBatch).toHaveBeenCalledTimes(1);
expect(setBatch).toHaveBeenCalledWith([
{ key: 'ui.theme', value: 'light' },
{ key: 'llm.provider', value: 'ollama' },
{ key: 'llm.model', value: 'qwen3:8b' },
]);
// 每条写入都有审计记录
expect((raw.auditService as { log: Mock }).log).toHaveBeenCalledTimes(3);
// 配置变更广播到渲染进程(前端 store 实时更新)
const broadcastKeys = broadcastMock.mock.calls
.filter(([ch]) => ch === 'config:changed')
.map(([, payload]) => (payload as { key: string }).key);
expect(broadcastKeys).toEqual(
expect.arrayContaining(['ui.theme', 'llm.provider', 'llm.model']),
);
});
it('config:setBatch 中 LLM key 变更触发 reloadAdapter;失败时整体报错', async () => {
const { ctx, raw } = makeCtx({ reloadAdapter: vi.fn(() => false) });
registerConfigHandlers(ctx);
const r = await getHandler('config:setBatch')(null, [{ key: 'llm.model', value: 'm' }]);
expect(r).toMatchObject({ success: false });
expect(raw.reloadAdapter as Mock).toHaveBeenCalled();
});
});
// ===== 追加:sessions 校验矩阵 =====
describe('sessions 域 — 补充校验矩阵', () => {
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
sessionService: {
list: vi.fn(() => []),
create: vi.fn(() => ({ id: 's1' })),
rename: vi.fn(() => true),
delete: vi.fn(() => true),
getMessages: vi.fn(() => []),
updateMessageContent: vi.fn(() => true),
pin: vi.fn(() => true),
archive: vi.fn(() => true),
clearMessages: vi.fn(),
truncateMessagesAfter: vi.fn(() => true),
searchMessages: vi.fn(() => []),
saveTraceData: vi.fn(),
getTraceData: vi.fn(() => null),
},
agentEngineManager: {
isRunning: vi.fn(() => false),
abort: vi.fn(),
waitForAbort: vi.fn().mockResolvedValue(true),
disposeEngine: vi.fn(),
},
confirmationHook: { forgetSession: vi.fn() },
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('updateMessageContent 校验:非法 sessionId / messageId / content 拒绝', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const handler = getHandler('sessions:updateMessageContent');
const svc = raw.sessionService as Record<string, Mock>;
expect(await handler(null, '', 'm1', 'x')).toMatchObject({ success: false });
expect(await handler(null, 's1', '', 'x')).toMatchObject({ success: false });
expect(await handler(null, 's1', 42, 'x')).toMatchObject({ success: false });
expect(await handler(null, 's1', 'm1', 123)).toMatchObject({ success: false });
expect(svc.updateMessageContent).not.toHaveBeenCalled();
await handler(null, 's1', 'm1', '新内容');
expect(svc.updateMessageContent).toHaveBeenCalledWith('s1', 'm1', '新内容');
});
it('updateMessageContent service 抛错 → 返回错误信息', async () => {
const { ctx } = makeCtx();
(ctx.sessionService as unknown as Record<string, Mock>).updateMessageContent = vi.fn(() => {
throw new Error('message not found');
});
registerSessionHandlers(ctx);
const result = (await getHandler('sessions:updateMessageContent')(
null,
's1',
'm_missing',
'x',
)) as { success: boolean; error?: string };
expect(result).toMatchObject({ success: false, error: 'message not found' });
});
it('sessions:delete 在会话运行中先中断再删除,且清理决策记忆 + 销毁引擎', async () => {
const { ctx, raw } = makeCtx();
(raw.agentEngineManager as Record<string, Mock>).isRunning = vi.fn(() => true);
registerSessionHandlers(ctx);
const handler = getHandler('sessions:delete');
const result = await handler(null, 's1');
expect(result).toEqual({ success: true });
expect((raw.agentEngineManager as Record<string, Mock>).abort).toHaveBeenCalledWith('s1');
expect((raw.agentEngineManager as Record<string, Mock>).waitForAbort).toHaveBeenCalledWith(
's1',
);
expect((raw.confirmationHook as Record<string, Mock>).forgetSession).toHaveBeenCalledWith('s1');
expect((raw.agentEngineManager as Record<string, Mock>).disposeEngine).toHaveBeenCalledWith(
's1',
);
});
it('sessions:delete 删除失败 → 不清理决策记忆/引擎', async () => {
const { ctx, raw } = makeCtx();
(raw.sessionService as Record<string, Mock>).delete = vi.fn(() => false);
registerSessionHandlers(ctx);
const handler = getHandler('sessions:delete');
const result = await handler(null, 's1');
expect(result).toEqual({ success: false });
expect((raw.confirmationHook as Record<string, Mock>).forgetSession).not.toHaveBeenCalled();
expect((raw.agentEngineManager as Record<string, Mock>).disposeEngine).not.toHaveBeenCalled();
});
it('sessions:clearMessages 操作完成即成功(A1 语义:空会话同样 success', async () => {
const { ctx } = makeCtx();
registerSessionHandlers(ctx);
expect(await getHandler('sessions:clearMessages')(null, 's1')).toEqual({ success: true });
});
it('sessions:truncateAfter:非法 messageId 拒绝;inclusive 缺省为 true', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const handler = getHandler('sessions:truncateAfter');
const svc = raw.sessionService as Record<string, Mock>;
expect(await handler(null, 's1', '', true)).toMatchObject({ success: false });
expect(svc.truncateMessagesAfter).not.toHaveBeenCalled();
await handler(null, 's1', 'm1');
expect(svc.truncateMessagesAfter).toHaveBeenCalledWith('s1', 'm1', true);
await handler(null, 's1', 'm1', false);
expect(svc.truncateMessagesAfter).toHaveBeenLastCalledWith('s1', 'm1', false);
});
it('sessions:searchContent 空查询拒绝;合法查询透传', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const handler = getHandler('sessions:searchContent');
const svc = raw.sessionService as Record<string, Mock>;
expect(await handler(null, ' ')).toMatchObject({ success: false });
expect(await handler(null, 42)).toMatchObject({ success: false });
await handler(null, '关键词');
expect(svc.searchMessages).toHaveBeenCalledWith('关键词');
});
it('sessions:pin/archive 合法布尔透传 service', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
await getHandler('sessions:pin')(null, 's1', true);
expect(svc.pin).toHaveBeenCalledWith('s1', true);
await getHandler('sessions:archive')(null, 's1', false);
expect(svc.archive).toHaveBeenCalledWith('s1', false);
});
it('sessions:rename 合法路径透传 title', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
const result = await getHandler('sessions:rename')(null, 's1', '新标题');
expect(result).toEqual({ success: true });
expect(svc.rename).toHaveBeenCalledWith('s1', '新标题');
});
it('sessions:list / create / getTrace 透传', async () => {
const { ctx, raw } = makeCtx();
registerSessionHandlers(ctx);
const svc = raw.sessionService as Record<string, Mock>;
await getHandler('sessions:list')(null);
expect(svc.list).toHaveBeenCalled();
await getHandler('sessions:create')(null, '标题');
expect(svc.create).toHaveBeenCalledWith('标题');
await getHandler('sessions:getTrace')(null, 's1');
expect(svc.getTraceData).toHaveBeenCalledWith('s1');
});
});
// ===== 追加:config:set URL 类键校验 =====
describe('config 域 — URL 类配置键高危目标校验(v0.7.4 P2-9-C', () => {
function makeCtx(overrides: Record<string, unknown> = {}): {
ctx: IPCContext;
raw: Record<string, unknown>;
} {
const raw = {
configService: {
get: vi.fn(() => null),
set: vi.fn(),
setBatch: vi.fn(),
},
auditService: { log: vi.fn() },
agentEngineManager: { updateConfigAll: vi.fn() },
orchestrator: { updateDefaultConfig: vi.fn() },
confirmationHook: { setConfirmationTimeout: vi.fn() },
reloadAdapter: vi.fn(() => true),
...overrides,
};
return { ctx: raw as unknown as IPCContext, raw };
}
it.each(['llm.baseURL', 'llm.fallbackBaseURL', 'searxng.url', 'app.updateFeedUrl'])(
'%s 指向云元数据 169.254.169.254 → 拒绝且不落库',
async (key) => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:set')(
null,
key,
'http://169.254.169.254/latest/meta-data',
);
expect(r).toMatchObject({ success: false });
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
},
);
it('llm.baseURL 指向 metadata.google.internal → 拒绝', async () => {
const { ctx } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:set')(
null,
'llm.baseURL',
'http://metadata.google.internal/computeMetadata/v1',
);
expect(r).toMatchObject({ success: false });
});
it('llm.baseURL 指向链路本地 169.254.16.0 段 → 拒绝', async () => {
const { ctx } = makeCtx();
registerConfigHandlers(ctx);
expect(await getHandler('config:set')(null, 'llm.baseURL', 'http://169.254.0.1')).toMatchObject(
{
success: false,
},
);
});
it('llm.baseURL 指向组播 224.0.0.1 → 拒绝', async () => {
const { ctx } = makeCtx();
registerConfigHandlers(ctx);
expect(await getHandler('config:set')(null, 'llm.baseURL', 'http://224.0.0.1')).toMatchObject({
success: false,
});
});
it('llm.baseURL 合法公网/本地回环 → 放行(本地 Ollama 是合法用例)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
for (const url of [
'http://localhost:11434',
'http://127.0.0.1:11434',
'http://192.168.1.5:8080',
'https://api.openai.com/v1',
]) {
const r = await getHandler('config:set')(null, 'llm.baseURL', url);
expect(r).toMatchObject({ success: true });
}
expect((raw.configService as { set: Mock }).set).toHaveBeenCalled();
});
it('非 URL 类键(ui.theme)不受 URL 校验影响', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:set')(null, 'ui.theme', 'dark');
expect(r).toEqual({ success: true });
expect((raw.configService as { set: Mock }).set).toHaveBeenCalledWith('ui.theme', 'dark');
});
it('config:setBatch 含高危 URL → 整体拒绝(批量不部分写入)', async () => {
const { ctx, raw } = makeCtx();
registerConfigHandlers(ctx);
const r = await getHandler('config:setBatch')(null, [
{ key: 'ui.theme', value: 'light' },
{ key: 'llm.baseURL', value: 'http://169.254.169.254' },
]);
expect(r).toMatchObject({ success: false });
expect((raw.configService as { setBatch: Mock }).setBatch).not.toHaveBeenCalled();
});
it('Provider 切换时旧 apiKey 被清空(C-1:防止不兼容 key 复用)', async () => {
const set = vi.fn();
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) => (key === 'llm.provider' ? 'deepseek' : null)),
set,
setBatch: vi.fn(),
},
});
registerConfigHandlers(ctx);
await getHandler('config:set')(null, 'llm.provider', 'openai');
expect(set).toHaveBeenCalledWith('llm.apiKey', '');
});
it('同一 Provider 重设不误清 apiKey', async () => {
const set = vi.fn();
const { ctx } = makeCtx({
configService: {
get: vi.fn((key: string) => (key === 'llm.provider' ? 'deepseek' : null)),
set,
setBatch: vi.fn(),
},
});
registerConfigHandlers(ctx);
await getHandler('config:set')(null, 'llm.provider', 'deepseek');
expect(set).not.toHaveBeenCalledWith('llm.apiKey', '');
});
});
// ===== 追加:tools 域补充 =====
describe('tools 域 — 补充契约', () => {
const TOOL_A = {
name: 'write_file',
description: 'd',
category: 'filesystem',
riskLevel: 'medium',
requiresPermission: true,
enabled: true,
};
function makeCtx(): { ctx: IPCContext; raw: Record<string, unknown> } {
const raw = {
toolRegistry: {
listAllTools: vi.fn(() => [TOOL_A]),
listTools: vi.fn(() => [TOOL_A]),
setToolEnabled: vi.fn(),
},
confirmationHook: {
resolveConfirmation: vi.fn(),
resolveConfirmationsBatch: vi.fn(() => ['tc_1']),
getPendingConfirmations: vi.fn(() => [{ id: 'tc_1' }]),
getRememberedDenials: vi.fn(() => []),
resetRememberedDenial: vi.fn(() => true),
setAutoExecute: vi.fn(),
getAutoExecuteList: vi.fn(() => []),
},
agentEngineManager: { setToolsAll: vi.fn() },
configService: { set: vi.fn() },
toolsReadyRef: { ready: true, toolCount: 1 },
};
return { ctx: raw as unknown as IPCContext, raw };
}
it('tools:toggle 参数类型非法(非字符串/非布尔)拒绝', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
expect(await getHandler('tools:toggle')(null, 42, true)).toMatchObject({ success: false });
expect(await getHandler('tools:toggle')(null, 'write_file', 'yes')).toMatchObject({
success: false,
});
expect(await getHandler('tools:toggle')(null, '', true)).toMatchObject({ success: false });
expect((raw.configService as { set: Mock }).set).not.toHaveBeenCalled();
});
it('tools:toggle 启用路径(enabled=true)同步三处', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const r = await getHandler('tools:toggle')(null, 'write_file', true);
expect(r).toEqual({ success: true });
expect((raw.configService as { set: Mock }).set).toHaveBeenCalledWith(
'tools.write_file.enabled',
true,
);
});
it('tool:getPendingConfirmations / getAutoExecuteList 透传', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const pending = (await getHandler('tool:getPendingConfirmations')(null)) as {
success: boolean;
data: unknown[];
};
expect(pending.success).toBe(true);
expect(pending.data).toHaveLength(1);
await getHandler('tool:getAutoExecuteList')(null);
expect(
(raw.confirmationHook as { getAutoExecuteList: Mock }).getAutoExecuteList,
).toHaveBeenCalled();
});
it('tool:resetRememberedDenial 非法 toolName 拒绝;合法返回 reset 结果', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const handler = getHandler('tool:resetRememberedDenial');
expect(await handler(null, '')).toMatchObject({ success: false });
expect(await handler(null, 42)).toMatchObject({ success: false });
const r = await handler(null, 'write_file', 's1');
expect(r).toEqual({ success: true });
expect(
(raw.confirmationHook as { resetRememberedDenial: Mock }).resetRememberedDenial,
).toHaveBeenCalledWith('write_file', 's1');
});
it('tool:confirmationResponse 部分合法字段的默认值(remember/autoExecute 缺省 false', async () => {
const { ctx, raw } = makeCtx();
registerToolHandlers(ctx);
const listener = getListener('tool:confirmationResponse');
const hook = (raw.confirmationHook as { resolveConfirmation: Mock }).resolveConfirmation;
listener(null, { toolCallId: 'tc_1', approved: true });
expect(hook).toHaveBeenCalledWith('tc_1', true, false, false);
});
});