/** * web_fetch 三阶段回退工具测试(v0.7.5 新建覆盖) * * 通过 mock ssrf-dispatcher(ssrfPinnedFetch/resolveRedirectTarget)、 * ssrf-guard(validateSSRF)与 browser(getBrowserManager)锁定: * - Phase1 HTTP 成功(text/html/markdown 三种 extract_mode) * - Phase2 内容过短自动升级浏览器 * - Phase3 浏览器回退 / 拦截页检测 / 重定向逐跳 / blocked 不进回退 * - 缓存命中 / max_chars 截断 / 10MB 闸门 / retry 语义 * * 注意:HTML 夹具必须 > 80 字符(绕过 isInterceptedPage 空壳判定), * text 模式正文必须 >= 200 字符(避免 Phase2 自动升级)。 */ 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(), // 类型签名与 ssrf-dispatcher.ts 的 resolveRedirectTarget 一致(string | null), // 使测试可通过 mockReturnValueOnce 注入下一跳 URL。 resolveRedirectTarget: vi.fn((_response: unknown, _url: string): string | null => null), })); vi.mock('../ssrf-dispatcher', () => ({ ssrfPinnedFetch: dispatcherMock.ssrfPinnedFetch, resolveRedirectTarget: dispatcherMock.resolveRedirectTarget, })); const browserMock = vi.hoisted(() => ({ getBrowserManager: vi.fn(), })); vi.mock('../browser', () => ({ getBrowserManager: browserMock.getBrowserManager, })); import { WebFetchTool } from '../web-fetch'; import { fetchCache } from '../network-utils'; import type { ToolExecutionContext } from '../../../types/metona-tool'; const context: ToolExecutionContext = { sessionId: 't', workspacePath: process.cwd(), iteration: 1, requestId: 'r', }; /** 生成足够长(> 80 字符)避免空壳拦截判定的 HTML 页面 */ function page(body: string, status = 200): Response { const html = `
${body}Padding text to exceed the minimum shell detection threshold of eighty characters in total length.
`; return new Response(html, { status, headers: { 'Content-Type': 'text/html' } }); } /** 生成文本内容 >= 200 字符的页面(避免 Phase2 升级) */ function longTextPage(text: string, status = 200): Response { const body = `${text}
${'padding-'.repeat(30)}
`; return page(body, status); } /** 生成带 Location 头的重定向响应 */ function redirectResponse(location: string, status = 302): Response { return new Response('', { status, headers: { Location: location } }); } /** 每次调用生成全新 Response(避免 body 消费后复用报错) */ function mockFetchWith(factory: () => Response) { dispatcherMock.ssrfPinnedFetch.mockImplementation(async () => factory()); } interface FetchResult { success: boolean; error?: string; content?: string; method?: string; truncated?: boolean; original_length?: number; extract_mode?: string; } function resetAllMocks(): void { vi.clearAllMocks(); ssrfMock.validateSSRF.mockReset().mockImplementation(async () => undefined); dispatcherMock.ssrfPinnedFetch.mockReset(); dispatcherMock.resolveRedirectTarget.mockReset().mockReturnValue(null); browserMock.getBrowserManager.mockReset().mockReturnValue({ fetchPageText: vi.fn(async () => 'browser rendered content '.repeat(30)), }); } describe('web_fetch — URL 与 SSRF 入口', () => { let tool: WebFetchTool; beforeEach(() => { tool = new WebFetchTool(); fetchCache.clear(); resetAllMocks(); }); afterEach(() => vi.clearAllMocks()); it('非 http/https URL → 拒绝', async () => { const r = (await tool.execute({ url: 'file:///etc/passwd' }, context)) as FetchResult; expect(r.success).toBe(false); expect(String(r.error)).toContain('URL must start with'); }); it('缺 url → 拒绝', async () => { const r = (await tool.execute({}, context)) as FetchResult; expect(r.success).toBe(false); }); it('SSRF 校验失败 → 拒绝且不进入浏览器回退', async () => { ssrfMock.validateSSRF.mockRejectedValueOnce( new Error('Blocked SSRF: private/loopback address'), ); const r = (await tool.execute({ url: 'http://127.0.0.1:1/x' }, context)) as FetchResult; expect(r.success).toBe(false); expect(String(r.error)).toContain('Blocked SSRF'); expect(dispatcherMock.ssrfPinnedFetch).not.toHaveBeenCalled(); expect(browserMock.getBrowserManager).not.toHaveBeenCalled(); }); it('SSRF 校验异常信息直接回传', async () => { ssrfMock.validateSSRF.mockRejectedValueOnce(new Error('Blocked SSRF: no DNS records')); const r = (await tool.execute({ url: 'http://nx.test/' }, context)) as FetchResult; expect(r.success).toBe(false); expect(String(r.error)).toContain('no DNS records'); }); }); describe('web_fetch — Phase1 HTTP 抓取', () => { let tool: WebFetchTool; beforeEach(() => { tool = new WebFetchTool(); fetchCache.clear(); resetAllMocks(); }); afterEach(() => vi.clearAllMocks()); it('text 模式:成功解析 HTML 为纯文本', async () => { mockFetchWith(() => longTextPage('标题段落与正文内容 ABC')); const r = (await tool.execute({ url: 'https://ok.test/page' }, context)) as FetchResult; expect(r.success).toBe(true); expect(r.method).toBe('http'); expect(String(r.content)).toContain('标题段落与正文内容 ABC'); expect(r.extract_mode).toBe('text'); }); it('html 模式:返回原始 HTML 原文(实况契约:原文透传不做清理)', async () => { mockFetchWith(() => page('正文
')); const r = (await tool.execute( { url: 'https://ok.test/md', extract_mode: 'markdown' }, context, )) as FetchResult; expect(r.success).toBe(true); expect(r.method).toBe('http'); expect(String(r.content)).toContain('# MD 标题'); expect(String(r.content)).toContain('[链接](https://x.test/)'); expect(r.extract_mode).toBe('markdown'); }); it('拦截页检测(Cloudflare)→ 触发浏览器回退', async () => { mockFetchWith( () => new Response('短内容
')); browserMock.getBrowserManager.mockReturnValue({ fetchPageText: vi.fn(async () => 'browser rich content '.repeat(30)), }); const r = (await tool.execute({ url: 'https://spa.test/' }, context)) as FetchResult; expect(r.success).toBe(true); expect(r.method).toBe('browser'); expect(String(r.content)).toContain('browser rich content'); }); it('Phase2 升级但浏览器也失败 → 返回 Phase2 的短内容(http 方法)', async () => { mockFetchWith(() => page('短内容保留
')); browserMock.getBrowserManager.mockReturnValue({ fetchPageText: vi.fn(async () => null) }); const r = (await tool.execute({ url: 'https://spa2.test/' }, context)) as FetchResult; expect(r.success).toBe(true); expect(r.method).toBe('http'); expect(String(r.content)).toContain('短内容保留'); }); it('html 模式不触发 Phase2 升级', async () => { mockFetchWith(() => page('键隔离正文内容足够长
')); await tool.execute({ url: 'https://key.test/', extract_mode: 'text' }, context); await tool.execute({ url: 'https://key.test/', extract_mode: 'html' }, context); expect(dispatcherMock.ssrfPinnedFetch).toHaveBeenCalledTimes(2); }); it('html 模式不写缓存(避免模式混淆)', async () => { mockFetchWith(() => page('不缓存正文
')); await tool.execute({ url: 'https://nocache.test/', extract_mode: 'html' }, context); expect(fetchCache.has('html:https://nocache.test/')).toBe(false); }); it('max_chars 截断内容并标记 truncated', async () => { mockFetchWith(() => page(`${'x'.repeat(5000)}
`)); const r = (await tool.execute( { url: 'https://max.test/', max_chars: 100 }, context, )) as FetchResult; expect(r.success).toBe(true); expect(r.truncated).toBe(true); expect(String(r.content)).toContain('content truncated at 100 chars'); expect(Number(r.original_length)).toBeGreaterThan(100); }); it('浏览器回退文本 > 500K 被截断', async () => { mockFetchWith(() => page('', 404)); browserMock.getBrowserManager.mockReturnValue({ fetchPageText: vi.fn(async () => 'B'.repeat(600_000)), }); const r = (await tool.execute({ url: 'https://bigbrowser.test/' }, context)) as FetchResult; expect(r.success).toBe(true); expect(String(r.content)).toContain('content truncated'); }); it('浏览器回退产出的拦截页 → 判为失败', async () => { mockFetchWith(() => page('', 403)); browserMock.getBrowserManager.mockReturnValue({ fetchPageText: vi.fn(async () => 'Just a moment... verifying you are human'), }); const r = (await tool.execute( { url: 'https://browser-intercepted.test/' }, context, )) as FetchResult; expect(r.success).toBe(false); expect(String(r.error)).toContain('All phases failed'); }); it('浏览器回退文本 < 80 字符 → 判为失败', async () => { mockFetchWith(() => page('', 404)); browserMock.getBrowserManager.mockReturnValue({ fetchPageText: vi.fn(async () => 'too short'), }); const r = (await tool.execute({ url: 'https://short.test/' }, context)) as FetchResult; expect(r.success).toBe(false); }); it('浏览器回退文本写入缓存 → 第二次请求直接命中顶层缓存(method=cache)', async () => { mockFetchWith(() => page('', 404)); browserMock.getBrowserManager.mockReturnValue({ fetchPageText: vi.fn(async () => 'browser cacheable content '.repeat(30)), }); await tool.execute({ url: 'https://browser-cache.test/' }, context); // 第二次:顶层 fetchCache(text:url 键,浏览器阶段写入)直接命中 const r = (await tool.execute({ url: 'https://browser-cache.test/' }, context)) as FetchResult; expect(r.success).toBe(true); expect(r.method).toBe('cache'); expect(String(r.content)).toContain('browser cacheable content'); // 浏览器不再被调用(缓存短路) expect(browserMock.getBrowserManager).toHaveBeenCalledTimes(1); }); });