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)
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
||
* http_request 工具测试(v0.7.5 新建覆盖)
|
||
*
|
||
* 通过 mock ssrf-guard(validateSSRF)与 ssrf-dispatcher(ssrfPinnedFetch)锁定:
|
||
* - 6 方法白名单 / 非法方法拒绝
|
||
* - GET/HEAD 不携带 body;POST/PUT/PATCH/DELETE 携带
|
||
* - SSRF 拦截 / 非法 URL
|
||
* - 响应截断 50KB / 头部过滤(仅 content-type/content-length/location)
|
||
* - 超时转译(AbortError/ETIMEDOUT → Request timeout)
|
||
* - redirect manual 语义
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
vi.mock('electron-log', () => ({
|
||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||
}));
|
||
|
||
const ssrfMock = vi.hoisted(() => ({
|
||
validateSSRF: vi.fn(async () => undefined),
|
||
}));
|
||
vi.mock('../ssrf-guard', () => ({
|
||
validateSSRF: ssrfMock.validateSSRF,
|
||
}));
|
||
|
||
const dispatcherMock = vi.hoisted(() => ({
|
||
ssrfPinnedFetch: vi.fn(),
|
||
}));
|
||
vi.mock('../ssrf-dispatcher', () => ({
|
||
ssrfPinnedFetch: dispatcherMock.ssrfPinnedFetch,
|
||
}));
|
||
|
||
import { HttpRequestTool } from '../http-request';
|
||
import type { ToolExecutionContext } from '../../../types/metona-tool';
|
||
|
||
const context: ToolExecutionContext = {
|
||
sessionId: 't',
|
||
workspacePath: process.cwd(),
|
||
iteration: 1,
|
||
requestId: 'r',
|
||
};
|
||
|
||
interface HttpResult {
|
||
success: boolean;
|
||
error?: string;
|
||
status?: number;
|
||
statusText?: string;
|
||
headers?: Record<string, string>;
|
||
body?: string;
|
||
truncated?: boolean;
|
||
ok?: boolean;
|
||
}
|
||
|
||
function makeResponse(
|
||
body: string,
|
||
init?: { status?: number; statusText?: string; headers?: Record<string, string> },
|
||
): Response {
|
||
return new Response(body, init);
|
||
}
|
||
|
||
describe('http_request — 入口校验', () => {
|
||
let tool: HttpRequestTool;
|
||
beforeEach(() => {
|
||
tool = new HttpRequestTool();
|
||
vi.clearAllMocks();
|
||
ssrfMock.validateSSRF.mockReset().mockImplementation(async () => undefined);
|
||
dispatcherMock.ssrfPinnedFetch.mockReset();
|
||
});
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
it('非法 URL(非 http/https)→ Invalid URL', async () => {
|
||
const r = (await tool.execute({ url: 'file:///etc/passwd' }, context)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(r.error).toBe('Invalid URL');
|
||
});
|
||
|
||
it('缺 url → Invalid URL', async () => {
|
||
const r = (await tool.execute({}, context)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(r.error).toBe('Invalid URL');
|
||
});
|
||
|
||
it('SSRF 校验失败 → 拒绝', async () => {
|
||
ssrfMock.validateSSRF.mockRejectedValueOnce(
|
||
new Error('Blocked SSRF: private/loopback address'),
|
||
);
|
||
const r = (await tool.execute({ url: 'http://127.0.0.1:8080/x' }, context)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(String(r.error)).toContain('Blocked SSRF');
|
||
expect(dispatcherMock.ssrfPinnedFetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('非法 method(大写归一后仍不在白名单)→ 拒绝', async () => {
|
||
const r = (await tool.execute(
|
||
{ url: 'https://a.test/', method: 'OPTIONS' },
|
||
context,
|
||
)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(String(r.error)).toContain('Invalid method');
|
||
});
|
||
|
||
it('method 小写自动归一为大写(post → POST 合法)', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok', { status: 200 }));
|
||
const r = (await tool.execute(
|
||
{ url: 'https://a.test/', method: 'post' },
|
||
context,
|
||
)) as HttpResult;
|
||
expect(r.success).toBe(true);
|
||
expect(dispatcherMock.ssrfPinnedFetch).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('6 个白名单方法全部放行', async () => {
|
||
// 每次调用生成全新 Response(避免 body 消费后复用报错)
|
||
dispatcherMock.ssrfPinnedFetch.mockImplementation(async () =>
|
||
makeResponse('ok', { status: 200 }),
|
||
);
|
||
for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']) {
|
||
const r = (await tool.execute({ url: 'https://a.test/', method }, context)) as HttpResult;
|
||
expect(r.success, `method ${method}`).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('缺省 method 为 GET', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('get-default', { status: 200 }));
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(true);
|
||
expect(r.body).toBe('get-default');
|
||
});
|
||
});
|
||
|
||
describe('http_request — 请求构造与响应处理', () => {
|
||
let tool: HttpRequestTool;
|
||
beforeEach(() => {
|
||
tool = new HttpRequestTool();
|
||
vi.clearAllMocks();
|
||
ssrfMock.validateSSRF.mockReset().mockImplementation(async () => undefined);
|
||
dispatcherMock.ssrfPinnedFetch.mockReset();
|
||
});
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
it('POST 携带 body;GET/HEAD 不携带 body', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
|
||
await tool.execute({ url: 'https://a.test/', method: 'POST', body: 'payload' }, context);
|
||
const postInit = dispatcherMock.ssrfPinnedFetch.mock.calls[0][1] as RequestInit;
|
||
expect(postInit.body).toBe('payload');
|
||
|
||
await tool.execute({ url: 'https://a.test/', method: 'GET', body: 'should-drop' }, context);
|
||
const getInit = dispatcherMock.ssrfPinnedFetch.mock.calls[1][1] as RequestInit;
|
||
expect(getInit.body).toBeUndefined();
|
||
|
||
await tool.execute({ url: 'https://a.test/', method: 'HEAD', body: 'should-drop' }, context);
|
||
const headInit = dispatcherMock.ssrfPinnedFetch.mock.calls[2][1] as RequestInit;
|
||
expect(headInit.body).toBeUndefined();
|
||
});
|
||
|
||
it('redirect 固定为 manual(防重定向绕过 SSRF)', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
|
||
await tool.execute({ url: 'https://a.test/' }, context);
|
||
const init = dispatcherMock.ssrfPinnedFetch.mock.calls[0][1] as RequestInit;
|
||
expect(init.redirect).toBe('manual');
|
||
});
|
||
|
||
it('自定义 headers 透传', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
|
||
await tool.execute(
|
||
{ url: 'https://a.test/', headers: { 'X-Custom': 'v1', Authorization: 'Bearer t' } },
|
||
context,
|
||
);
|
||
const init = dispatcherMock.ssrfPinnedFetch.mock.calls[0][1] as RequestInit;
|
||
expect(init.headers).toEqual({ 'X-Custom': 'v1', Authorization: 'Bearer t' });
|
||
});
|
||
|
||
it('响应截断到 50KB 并标记 truncated', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('y'.repeat(100_000)));
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(true);
|
||
expect(r.truncated).toBe(true);
|
||
expect(r.body?.length).toBe(50 * 1024);
|
||
});
|
||
|
||
it('小响应不截断', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('small'));
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.truncated).toBe(false);
|
||
expect(r.body).toBe('small');
|
||
});
|
||
|
||
it('头部过滤:仅保留 content-type/content-length/location', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(
|
||
makeResponse('body', {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': '4',
|
||
Location: 'https://next.test/',
|
||
'X-Secret': 'leak',
|
||
'Set-Cookie': 'sess=1',
|
||
},
|
||
}),
|
||
);
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.headers).toEqual({
|
||
'content-type': 'application/json',
|
||
'content-length': '4',
|
||
location: 'https://next.test/',
|
||
});
|
||
});
|
||
|
||
it('无额外头时只回传自动生成的 content-type(无 X-* 泄露)', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockImplementation(async () =>
|
||
makeResponse('x', { status: 200 }),
|
||
);
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
// Response 构造器自动生成 text/plain content-type;其余自定义头一律不泄露
|
||
expect(r.headers?.['content-type']).toBeDefined();
|
||
expect(Object.keys(r.headers ?? {}).every((k) => !k.toLowerCase().startsWith('x-'))).toBe(true);
|
||
});
|
||
|
||
it('3xx 重定向状态透传 + location 头部', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(
|
||
makeResponse('', {
|
||
status: 302,
|
||
statusText: 'Found',
|
||
headers: { Location: 'https://n.test/' },
|
||
}),
|
||
);
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(true);
|
||
expect(r.status).toBe(302);
|
||
expect(r.statusText).toBe('Found');
|
||
expect(r.ok).toBe(false);
|
||
expect(r.headers?.location).toBe('https://n.test/');
|
||
});
|
||
|
||
it('超时(AbortError)→ Request timeout', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockRejectedValue(
|
||
Object.assign(new Error('aborted'), { name: 'AbortError' }),
|
||
);
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(r.error).toBe('Request timeout');
|
||
});
|
||
|
||
it('超时(ETIMEDOUT)→ Request timeout', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockRejectedValue(
|
||
Object.assign(new Error('Request timed out after 30000ms'), { code: 'ETIMEDOUT' }),
|
||
);
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(r.error).toBe('Request timeout');
|
||
});
|
||
|
||
it('其他网络错误原样回传', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(false);
|
||
expect(r.error).toBe('ECONNREFUSED');
|
||
});
|
||
|
||
it('timeout 参数钳制(>60s 压到 60s,<1ms 抬到 1ms)', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('ok'));
|
||
await tool.execute({ url: 'https://a.test/', timeout: 999_999 }, context);
|
||
expect(dispatcherMock.ssrfPinnedFetch.mock.calls[0][2]).toBe(60_000);
|
||
await tool.execute({ url: 'https://a.test/', timeout: 0 }, context);
|
||
expect(dispatcherMock.ssrfPinnedFetch.mock.calls[1][2]).toBe(1);
|
||
});
|
||
|
||
it('非 2xx 状态仍返回 success=true(透传状态码,语义:请求本身成功)', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(makeResponse('err-body', { status: 500 }));
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(true);
|
||
expect(r.status).toBe(500);
|
||
expect(r.ok).toBe(false);
|
||
});
|
||
|
||
it('空 body 响应正常返回空串', async () => {
|
||
dispatcherMock.ssrfPinnedFetch.mockResolvedValue(new Response(null, { status: 204 }));
|
||
const r = (await tool.execute({ url: 'https://a.test/' }, context)) as HttpResult;
|
||
expect(r.success).toBe(true);
|
||
expect(r.body).toBe('');
|
||
});
|
||
});
|