basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach } from 'vitest';8import { WebFetchTool } from './web-fetch.js';9import type { Config } from '../config/config.js';10import { ApprovalMode } from '../config/config.js';11import { ToolConfirmationOutcome } from './tools.js';12import { ToolErrorType } from './tool-error.js';13import * as fetchUtils from '../utils/fetch.js';14 15// Mocks the underlying call BaseLlmClient.generateText makes; web-fetch's16// `runSideQuery` text-mode path lands on this mock.17const mockGenerateContent = vi.fn();18const mockGetBaseLlmClient = vi.fn(() => ({19 generateText: mockGenerateContent,20}));21 22vi.mock('../utils/fetch.js', async (importOriginal) => {23 const actual = await importOriginal<typeof fetchUtils>();24 return {25 ...actual,26 fetchWithTimeout: vi.fn(),27 isPrivateIp: vi.fn(),28 };29});30 31describe('WebFetchTool', () => {32 let mockConfig: Config;33 34 beforeEach(() => {35 vi.resetAllMocks();36 mockConfig = {37 getApprovalMode: vi.fn(),38 setApprovalMode: vi.fn(),39 getProxy: vi.fn(),40 getBaseLlmClient: mockGetBaseLlmClient,41 getFastModel: vi.fn(() => undefined),42 getSessionId: vi.fn(() => 'test-session-id'),43 getModel: vi.fn(() => 'qwen-coder'),44 } as unknown as Config;45 });46 47 describe('execute', () => {48 it('should throw validation error when url parameter is missing', async () => {49 const tool = new WebFetchTool(mockConfig);50 const params = { prompt: 'no url here' };51 /* @ts-expect-error - we are testing validation */52 expect(() => tool.build(params)).toThrow(53 "params must have required property 'url'",54 );55 });56 57 it.each(['HTTPS://example.com', 'Http://example.com'])(58 'should accept uppercase http url schemes: %s',59 (url) => {60 const tool = new WebFetchTool(mockConfig);61 expect(() =>62 tool.build({ url, prompt: 'summarize this' }),63 ).not.toThrow();64 },65 );66 67 it.each([68 [69 'ftp://example.com',70 "The 'url' must be a valid URL starting with http:// or https://.",71 ],72 [73 'http:example.com',74 "The 'url' must be a valid URL starting with http:// or https://.",75 ],76 [77 'http:/example.com',78 "The 'url' must be a valid URL starting with http:// or https://.",79 ],80 ['https://', "The 'url' is malformed and could not be parsed."],81 ['http://[::1', "The 'url' is malformed and could not be parsed."],82 ])(83 'should reject invalid or unsupported urls: %s',84 (url, expectedError) => {85 const tool = new WebFetchTool(mockConfig);86 expect(() => tool.build({ url, prompt: 'summarize this' })).toThrow(87 expectedError,88 );89 },90 );91 92 it.each([93 'https://user:secret@example.com/page',94 'http://user@example.com/page',95 'https://:secret@example.com/page',96 'https://%75ser@example.com/page',97 ])('should reject URLs containing credentials: %s', (url) => {98 const tool = new WebFetchTool(mockConfig);99 expect(() => tool.build({ url, prompt: 'summarize this' })).toThrow(100 "The 'url' must not include credentials.",101 );102 });103 104 it('should return WEB_FETCH_FALLBACK_FAILED on fetch failure', async () => {105 vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);106 vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(107 new Error('fetch failed'),108 );109 const tool = new WebFetchTool(mockConfig);110 const params = { url: 'https://private.ip', prompt: 'summarize this' };111 const invocation = tool.build(params);112 const result = await invocation.execute(new AbortController().signal);113 expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);114 });115 116 it('should return WEB_FETCH_FALLBACK_FAILED on API processing failure', async () => {117 vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);118 vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({119 ok: true,120 headers: new Headers({ 'content-type': 'text/html' }),121 text: () => Promise.resolve('<html><body>Test content</body></html>'),122 } as Response);123 mockGenerateContent.mockRejectedValue(new Error('API error'));124 const tool = new WebFetchTool(mockConfig);125 const params = { url: 'https://public.ip', prompt: 'summarize this' };126 const invocation = tool.build(params);127 const result = await invocation.execute(new AbortController().signal);128 expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);129 });130 });131 132 describe('format parameter', () => {133 it('should default to auto format when not specified', async () => {134 const fetchSpy = vi135 .spyOn(fetchUtils, 'fetchWithTimeout')136 .mockResolvedValue({137 ok: true,138 headers: new Headers({ 'content-type': 'text/html' }),139 text: () => Promise.resolve('<html><body>Test content</body></html>'),140 } as Response);141 142 mockGenerateContent.mockResolvedValue({143 response: { text: () => 'Summary' },144 });145 146 const tool = new WebFetchTool(mockConfig);147 const params = { url: 'https://example.com', prompt: 'summarize' };148 const invocation = tool.build(params);149 await invocation.execute(new AbortController().signal);150 151 expect(fetchSpy).toHaveBeenCalledWith(152 'https://example.com',153 expect.any(Number),154 {155 Accept: 'text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1',156 },157 );158 });159 160 it('should prefer markdown when format is markdown', async () => {161 const fetchSpy = vi162 .spyOn(fetchUtils, 'fetchWithTimeout')163 .mockResolvedValue({164 ok: true,165 headers: new Headers({ 'content-type': 'text/markdown' }),166 text: () => Promise.resolve('# Test Content'),167 } as Response);168 169 mockGenerateContent.mockResolvedValue({170 response: { text: () => 'Summary' },171 });172 173 const tool = new WebFetchTool(mockConfig);174 const params = {175 url: 'https://example.com',176 prompt: 'summarize',177 format: 'markdown' as const,178 };179 const invocation = tool.build(params);180 await invocation.execute(new AbortController().signal);181 182 expect(fetchSpy).toHaveBeenCalledWith(183 'https://example.com',184 expect.any(Number),185 { Accept: 'text/markdown, */*;q=0.1' },186 );187 });188 189 it('should prefer HTML when format is html', async () => {190 const fetchSpy = vi191 .spyOn(fetchUtils, 'fetchWithTimeout')192 .mockResolvedValue({193 ok: true,194 headers: new Headers({ 'content-type': 'text/html' }),195 text: () => Promise.resolve('<html><body>Test content</body></html>'),196 } as Response);197 198 mockGenerateContent.mockResolvedValue({199 response: { text: () => 'Summary' },200 });201 202 const tool = new WebFetchTool(mockConfig);203 const params = {204 url: 'https://example.com',205 prompt: 'summarize',206 format: 'html' as const,207 };208 const invocation = tool.build(params);209 await invocation.execute(new AbortController().signal);210 211 expect(fetchSpy).toHaveBeenCalledWith(212 'https://example.com',213 expect.any(Number),214 { Accept: 'text/html, */*;q=0.1' },215 );216 });217 218 it('should prefer plain text when format is text', async () => {219 const fetchSpy = vi220 .spyOn(fetchUtils, 'fetchWithTimeout')221 .mockResolvedValue({222 ok: true,223 headers: new Headers({ 'content-type': 'text/plain' }),224 text: () => Promise.resolve('Plain text content'),225 } as Response);226 227 mockGenerateContent.mockResolvedValue({228 response: { text: () => 'Summary' },229 });230 231 const tool = new WebFetchTool(mockConfig);232 const params = {233 url: 'https://example.com',234 prompt: 'summarize',235 format: 'text' as const,236 };237 const invocation = tool.build(params);238 await invocation.execute(new AbortController().signal);239 240 expect(fetchSpy).toHaveBeenCalledWith(241 'https://example.com',242 expect.any(Number),243 { Accept: 'text/plain, */*;q=0.1' },244 );245 });246 247 it('should process JSON content returned by fallback content negotiation', async () => {248 let receivedContent = '';249 vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({250 ok: true,251 headers: new Headers({ 'content-type': 'application/json' }),252 text: () =>253 Promise.resolve(254 JSON.stringify({255 published_at: '2026-01-27T11:50:52Z',256 body: '<p>Release <b>notes</b></p>',257 desc: 'Use & for ampersand',258 }),259 ),260 } as Response);261 262 mockGenerateContent.mockImplementation((options) => {263 receivedContent = options.contents[0].parts[0].text;264 return Promise.resolve({ text: 'Processed', usage: undefined });265 });266 267 const tool = new WebFetchTool(mockConfig);268 const params = {269 url: 'https://api.github.com/repos/openai/codex/releases/tags/rust-v0.92.0',270 prompt: 'report the published date',271 };272 const invocation = tool.build(params);273 await invocation.execute(new AbortController().signal);274 275 expect(receivedContent).toContain('published_at');276 expect(receivedContent).toContain('2026-01-27T11:50:52Z');277 expect(receivedContent).toContain('<p>Release <b>notes</b></p>');278 expect(receivedContent).toContain('Use & for ampersand');279 });280 281 it('should include markdown content in prompt when server returns markdown', async () => {282 let receivedContent = '';283 vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({284 ok: true,285 headers: new Headers({286 'content-type': 'text/markdown; charset=utf-8',287 }),288 text: () =>289 Promise.resolve('# Hello World\n\nThis is markdown content.'),290 } as Response);291 292 mockGenerateContent.mockImplementation((options) => {293 receivedContent = options.contents[0].parts[0].text;294 return Promise.resolve({ text: 'Processed', usage: undefined });295 });296 297 const tool = new WebFetchTool(mockConfig);298 const params = { url: 'https://example.com', prompt: 'summarize' };299 const invocation = tool.build(params);300 await invocation.execute(new AbortController().signal);301 302 expect(receivedContent).toContain('# Hello World');303 });304 305 it('should include plain text content in prompt when server returns plain text', async () => {306 let receivedContent = '';307 vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({308 ok: true,309 headers: new Headers({ 'content-type': 'text/plain' }),310 text: () => Promise.resolve('Plain text content here'),311 } as Response);312 313 mockGenerateContent.mockImplementation((options) => {314 receivedContent = options.contents[0].parts[0].text;315 return Promise.resolve({ text: 'Processed', usage: undefined });316 });317 318 const tool = new WebFetchTool(mockConfig);319 const params = {320 url: 'https://example.com',321 prompt: 'summarize',322 format: 'text' as const,323 };324 const invocation = tool.build(params);325 await invocation.execute(new AbortController().signal);326 327 expect(receivedContent).toContain('Plain text content here');328 });329 });330 331 describe('getConfirmationDetails', () => {332 it('should return confirmation details with the correct prompt and urls', async () => {333 const tool = new WebFetchTool(mockConfig);334 const params = {335 url: 'https://example.com',336 prompt: 'summarize this page',337 };338 const invocation = tool.build(params);339 expect(await invocation.getDefaultPermission()).toBe('ask');340 341 const confirmationDetails = await invocation.getConfirmationDetails(342 new AbortController().signal,343 );344 345 expect(confirmationDetails).toEqual({346 type: 'info',347 title: 'Confirm Web Fetch',348 prompt:349 'Fetch content from https://example.com and process with: summarize this page',350 urls: ['https://example.com'],351 permissionRules: ['WebFetch(example.com)'],352 onConfirm: expect.any(Function),353 });354 });355 356 it('should return github urls as-is in confirmation details', async () => {357 const tool = new WebFetchTool(mockConfig);358 const params = {359 url: 'https://github.com/google/gemini-react/blob/main/README.md',360 prompt: 'summarize the README',361 };362 const invocation = tool.build(params);363 expect(await invocation.getDefaultPermission()).toBe('ask');364 365 const confirmationDetails = await invocation.getConfirmationDetails(366 new AbortController().signal,367 );368 369 expect(confirmationDetails).toEqual({370 type: 'info',371 title: 'Confirm Web Fetch',372 prompt:373 'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',374 urls: ['https://github.com/google/gemini-react/blob/main/README.md'],375 permissionRules: ['WebFetch(github.com)'],376 onConfirm: expect.any(Function),377 });378 });379 380 it('should return ask even if approval mode is AUTO_EDIT (approval mode handled by scheduler)', async () => {381 const tool = new WebFetchTool({382 ...mockConfig,383 getApprovalMode: () => ApprovalMode.AUTO_EDIT,384 } as unknown as Config);385 const params = {386 url: 'https://example.com',387 prompt: 'summarize this page',388 };389 const invocation = tool.build(params);390 expect(await invocation.getDefaultPermission()).toBe('ask');391 392 const confirmationDetails = await invocation.getConfirmationDetails(393 new AbortController().signal,394 );395 396 expect(confirmationDetails).toEqual({397 type: 'info',398 title: 'Confirm Web Fetch',399 prompt:400 'Fetch content from https://example.com and process with: summarize this page',401 urls: ['https://example.com'],402 permissionRules: ['WebFetch(example.com)'],403 onConfirm: expect.any(Function),404 });405 });406 407 it('should have onConfirm as a no-op (approval mode handled by scheduler)', async () => {408 const setApprovalMode = vi.fn();409 const testConfig = {410 ...mockConfig,411 setApprovalMode,412 } as unknown as Config;413 const tool = new WebFetchTool(testConfig);414 const params = {415 url: 'https://example.com',416 prompt: 'summarize this page',417 };418 const invocation = tool.build(params);419 const confirmationDetails = await invocation.getConfirmationDetails(420 new AbortController().signal,421 );422 423 if (424 confirmationDetails &&425 typeof confirmationDetails === 'object' &&426 'onConfirm' in confirmationDetails427 ) {428 await confirmationDetails.onConfirm(429 ToolConfirmationOutcome.ProceedAlways,430 );431 }432 433 // setApprovalMode should NOT be called — onConfirm is a no-op434 expect(setApprovalMode).not.toHaveBeenCalled();435 });436 });437});438 