/** * 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; body?: string; truncated?: boolean; ok?: boolean; } function makeResponse( body: string, init?: { status?: number; statusText?: string; headers?: Record }, ): 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(''); }); });