Files
promptfoo--promptfoo/test/monkeyPatchFetch.test.ts
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

512 lines
19 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CONSENT_ENDPOINT, EVENTS_ENDPOINT, R_ENDPOINT } from '../src/constants';
import { CLOUD_API_HOST, cloudConfig } from '../src/globalConfig/cloud';
import logger, { logRequestResponse } from '../src/logger';
import { createMockResponse } from './util/utils';
vi.mock('../src/logger', () => ({
__esModule: true,
default: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
logRequestResponse: vi.fn(),
}));
vi.mock('../src/globalConfig/cloud', () => ({
CLOUD_API_HOST: 'https://api.promptfoo.dev',
cloudConfig: {
getApiHost: vi.fn(),
getApiKey: vi.fn(),
getCurrentOrganizationId: vi.fn(),
getCurrentTeamId: vi.fn(),
},
}));
describe('monkeyPatchFetch', () => {
let mockOriginalFetch: ReturnType<typeof vi.fn>;
let monkeyPatchFetch: any;
beforeAll(async () => {
// Mock global fetch before importing the module
mockOriginalFetch = vi.fn();
Object.defineProperty(global, 'fetch', {
value: mockOriginalFetch,
writable: true,
});
// Now import the module after mocking global fetch
const module = await import('../src/util/fetch/monkeyPatchFetch');
monkeyPatchFetch = module.monkeyPatchFetch;
});
beforeEach(() => {
vi.mocked(cloudConfig.getApiHost).mockReturnValue(CLOUD_API_HOST);
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
vi.mocked(cloudConfig.getCurrentOrganizationId).mockReturnValue(undefined);
vi.mocked(cloudConfig.getCurrentTeamId).mockReturnValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllEnvs();
});
it('should log successful requests and responses', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const url = 'https://example.com/api';
const options = {
method: 'POST',
body: JSON.stringify({ test: 'data' }),
headers: { 'Content-Type': 'application/json' },
};
await monkeyPatchFetch(url, options);
expect(logRequestResponse).toHaveBeenCalledWith({
url: url,
requestBody: options.body,
requestMethod: 'POST',
response: mockResponse,
});
});
it('should not log requests to excluded endpoints', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const excludedUrls = [
R_ENDPOINT + '/test',
CONSENT_ENDPOINT + '/test',
EVENTS_ENDPOINT + '/test',
];
for (const url of excludedUrls) {
await monkeyPatchFetch(url);
expect(logRequestResponse).not.toHaveBeenCalled();
vi.mocked(logRequestResponse).mockClear();
}
});
it('should add Authorization header for cloud API requests when token is available', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const apiKey = 'test-api-key-123';
vi.mocked(cloudConfig.getApiKey).mockReturnValue(apiKey);
const url = CLOUD_API_HOST + '/api/test';
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
});
it('should add authorization and current team for configured on-prem cloud task requests', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const apiKey = 'test-api-key-123';
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://onprem.example.com/api');
vi.mocked(cloudConfig.getApiKey).mockReturnValue(apiKey);
vi.mocked(cloudConfig.getCurrentOrganizationId).mockReturnValue('org-1');
vi.mocked(cloudConfig.getCurrentTeamId).mockReturnValue('team-current');
const url = 'https://onprem.example.com/api/v1/task';
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
'x-promptfoo-team-id': 'team-current',
},
});
});
it.each([
'/api/v1/task',
'/api/v1/task/harmful',
])('should add the current CLI team to Cloud task requests at %s', async (pathname) => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getCurrentOrganizationId).mockReturnValue('org-1');
vi.mocked(cloudConfig.getCurrentTeamId).mockReturnValue('team-current');
const url = CLOUD_API_HOST + pathname;
await monkeyPatchFetch(url);
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
expect(new Headers(requestInit.headers).get('x-promptfoo-team-id')).toBe('team-current');
expect(cloudConfig.getCurrentTeamId).toHaveBeenCalledWith('org-1');
});
it('should not add the current CLI team to non-task Cloud requests', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getCurrentOrganizationId).mockReturnValue('org-1');
vi.mocked(cloudConfig.getCurrentTeamId).mockReturnValue('team-current');
const url = CLOUD_API_HOST + '/api/v1/users/me';
await monkeyPatchFetch(url);
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
expect(new Headers(requestInit.headers).has('x-promptfoo-team-id')).toBe(false);
});
it.each([
'https://custom.example.com/harmful',
'https://api.promptfoo.dev.attacker.example.com/api/v1/task',
])('should not forward cloud credentials to %s', async (url) => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key-123');
vi.mocked(cloudConfig.getCurrentOrganizationId).mockReturnValue('org-1');
vi.mocked(cloudConfig.getCurrentTeamId).mockReturnValue('team-current');
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {});
});
it('should not override a caller-supplied team header', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getCurrentOrganizationId).mockReturnValue('org-1');
vi.mocked(cloudConfig.getCurrentTeamId).mockReturnValue('team-current');
const url = CLOUD_API_HOST + '/api/v1/task';
await monkeyPatchFetch(url, {
headers: { 'x-promptfoo-team-id': 'team-explicit' },
});
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
expect(new Headers(requestInit.headers).get('x-promptfoo-team-id')).toBe('team-explicit');
});
it('should attach the token for a port-bearing on-prem cloud host', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const apiKey = 'test-api-key-123';
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://onprem.example.com:8443');
vi.mocked(cloudConfig.getApiKey).mockReturnValue(apiKey);
const url = 'https://onprem.example.com:8443/api/v1/task';
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
});
it.each([
'https://onprem.example.com/api/v1/task', // same host, default :443 -> different origin
'http://onprem.example.com:8443/api/v1/task', // same host:port -> http vs https
])('should not attach the token to %s when the cloud host is https://onprem.example.com:8443', async (url) => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://onprem.example.com:8443');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key-123');
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {});
});
it.each([
'',
'not a url',
'onprem.example.com',
])('should fail closed (no token) when getApiHost() returns the unparseable value %p', async (host) => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiHost).mockReturnValue(host);
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key-123');
const url = 'https://api.promptfoo.dev/api/v1/task';
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {});
});
it.each([
'Authorization',
'authorization',
])('should not override a caller-supplied %s header for cloud requests', async (headerName) => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
// The saved token differs from the caller-supplied one: e.g. validating/rotating
// a new key against an already-logged-in cloud host must send the new token, not
// the saved one. Regression guard for CloudConfig.validateApiToken().
vi.mocked(cloudConfig.getApiKey).mockReturnValue('saved-token');
const url = CLOUD_API_HOST + '/api/v1/users/me';
await monkeyPatchFetch(url, { headers: { [headerName]: 'Bearer caller-token' } });
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {
headers: { [headerName]: 'Bearer caller-token' },
});
});
it('should not override Authorization embedded in a cloud Request', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiKey).mockReturnValue('saved-token');
const request = new Request(CLOUD_API_HOST + '/api/v1/users/me', {
headers: { Authorization: 'Bearer caller-token' },
});
await monkeyPatchFetch(request);
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
expect(request.headers.get('authorization')).toBe('Bearer caller-token');
expect(requestInit.headers).toBeUndefined();
});
it('should not override Authorization supplied with a Headers instance', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiKey).mockReturnValue('saved-token');
const url = CLOUD_API_HOST + '/api/v1/users/me';
const headers = new Headers({ Authorization: 'Bearer caller-token' });
await monkeyPatchFetch(url, { headers });
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
expect(new Headers(requestInit.headers).get('authorization')).toBe('Bearer caller-token');
});
it('should preserve cloud Request headers when adding Authorization', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiKey).mockReturnValue('saved-token');
const request = new Request(CLOUD_API_HOST + '/api/test', {
headers: { 'X-Custom-Header': 'custom-value' },
});
await monkeyPatchFetch(request);
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
const headers = new Headers(requestInit.headers);
expect(headers.get('authorization')).toBe('Bearer saved-token');
expect(headers.get('x-custom-header')).toBe('custom-value');
});
it('should preserve Headers-instance headers when compressing', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
// Non-cloud host so no auth injection — isolates the compression header merge.
const headers = new Headers({ 'X-Custom-Header': 'custom-value' });
await monkeyPatchFetch('https://example.com/api', {
method: 'POST',
body: JSON.stringify({ test: 'data' }),
headers,
compress: true,
});
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
const sentHeaders = new Headers(requestInit.headers);
expect(sentHeaders.get('content-encoding')).toBe('gzip');
expect(sentHeaders.get('x-custom-header')).toBe('custom-value');
});
it('should preserve array-tuple headers when adding Authorization', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiKey).mockReturnValue('saved-token');
const url = CLOUD_API_HOST + '/api/test';
await monkeyPatchFetch(url, { headers: [['X-Custom-Header', 'custom-value']] });
const requestInit = mockOriginalFetch.mock.calls[0][1] as RequestInit;
const headers = new Headers(requestInit.headers);
expect(headers.get('authorization')).toBe('Bearer saved-token');
expect(headers.get('x-custom-header')).toBe('custom-value');
});
it('should suppress logging for a Request to an excluded endpoint', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
await monkeyPatchFetch(new Request(R_ENDPOINT + '/test'));
expect(logRequestResponse).not.toHaveBeenCalled();
});
it.each([
EVENTS_ENDPOINT + '.evil.example/collect',
CONSENT_ENDPOINT + '-status',
])('should log Request URLs that only share a string prefix with an excluded endpoint: %s', async (url) => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
await monkeyPatchFetch(new Request(url));
expect(logRequestResponse).toHaveBeenCalledWith(expect.objectContaining({ url }));
});
it('should log the resolved URL (not "[object Request]") for Request inputs', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const url = 'https://example.com/api';
await monkeyPatchFetch(new Request(url));
expect(logRequestResponse).toHaveBeenCalledWith(expect.objectContaining({ url }));
});
it('should not add Authorization header for cloud API requests when no token available', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
const url = CLOUD_API_HOST + '/api/test';
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {});
});
it('should handle URL objects for cloud API requests', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const apiKey = 'test-api-key-123';
vi.mocked(cloudConfig.getApiKey).mockReturnValue(apiKey);
const url = new URL('/api/test', CLOUD_API_HOST);
await monkeyPatchFetch(url);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
});
it('should preserve existing headers when adding Authorization', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const apiKey = 'test-api-key-123';
vi.mocked(cloudConfig.getApiKey).mockReturnValue(apiKey);
const url = CLOUD_API_HOST + '/api/test';
const options = {
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value',
},
};
await monkeyPatchFetch(url, options);
expect(mockOriginalFetch).toHaveBeenCalledWith(url, {
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value',
Authorization: `Bearer ${apiKey}`,
},
});
});
it('should log errors with request details', async () => {
const error = new Error('Network error');
mockOriginalFetch.mockRejectedValue(error);
const url = 'https://example.com/api';
const options = {
method: 'POST',
body: JSON.stringify({ test: 'data' }),
};
await expect(monkeyPatchFetch(url, options)).rejects.toThrow('Network error');
expect(logRequestResponse).toHaveBeenCalledWith({
url: url,
requestBody: options.body,
requestMethod: 'POST',
response: null,
});
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Error in fetch:'));
});
it('should handle connection errors with specific messaging', async () => {
const connectionError = new TypeError('fetch failed');
connectionError.cause = {
stack: 'Error: connect ECONNREFUSED\n at internalConnectMultiple',
};
mockOriginalFetch.mockRejectedValue(connectionError);
const url = 'https://example.com/api';
await expect(monkeyPatchFetch(url)).rejects.toThrow('fetch failed');
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Connection error, please check your network connectivity'),
);
});
it('should redact credentials in connection error target and proxy URLs', async () => {
const connectionError = new TypeError('fetch failed');
connectionError.cause = {
stack: 'Error: connect ECONNREFUSED\n at internalConnectMultiple',
};
mockOriginalFetch.mockRejectedValue(connectionError);
vi.stubEnv('HTTP_PROXY', 'https://proxy-user:proxy-password@proxy.example.com?token=proxy-key');
const url =
'https://webhook-user:webhook-password@n8n.example.com/webhook/agent?token=webhook-secret';
await expect(monkeyPatchFetch(url)).rejects.toThrow('fetch failed');
const debugLogs = JSON.stringify(vi.mocked(logger.debug).mock.calls);
expect(debugLogs).toContain('n8n.example.com');
expect(debugLogs).toContain('proxy.example.com');
expect(debugLogs).not.toContain('webhook-user');
expect(debugLogs).not.toContain('webhook-password');
expect(debugLogs).not.toContain('webhook-secret');
expect(debugLogs).not.toContain('proxy-user');
expect(debugLogs).not.toContain('proxy-password');
expect(debugLogs).not.toContain('proxy-key');
});
it('should not log errors for excluded endpoints', async () => {
const error = new Error('Network error');
mockOriginalFetch.mockRejectedValue(error);
const url = R_ENDPOINT + '/test';
await expect(monkeyPatchFetch(url)).rejects.toThrow('Network error');
expect(logRequestResponse).not.toHaveBeenCalled();
expect(logger.debug).not.toHaveBeenCalled();
});
it('should default to GET method when none provided', async () => {
const mockResponse = createMockResponse({ ok: true, status: 200 });
mockOriginalFetch.mockResolvedValue(mockResponse);
const url = 'https://example.com/api';
await monkeyPatchFetch(url);
expect(logRequestResponse).toHaveBeenCalledWith({
url: url,
requestBody: undefined,
requestMethod: 'GET',
response: mockResponse,
});
});
});