"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const undici_1 = require("undici");
const cliState_1 = __importDefault(require("../src/cliState"));
const constants_1 = require("../src/constants");
const envars_1 = require("../src/envars");
const fetch_1 = require("../src/fetch");
const logger_1 = __importDefault(require("../src/logger"));
const time_1 = require("../src/util/time");
const utils_1 = require("./util/utils");
jest.mock('../src/util/time', () => ({
    sleep: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('undici', () => {
    return {
        ProxyAgent: jest.fn().mockImplementation((options) => ({
            options,
            addRequest: jest.fn(),
            destroy: jest.fn(),
        })),
        setGlobalDispatcher: jest.fn(),
    };
});
jest.mock('../src/logger');
jest.mock('../src/envars', () => ({
    getEnvString: jest.fn().mockImplementation((key, defaultValue = '') => ''),
    getEnvBool: jest.fn().mockImplementation((key, defaultValue = false) => false),
    getEnvInt: jest.fn().mockReturnValue(100),
}));
jest.mock('fs', () => ({
    readFileSync: jest.fn(),
}));
jest.mock('../src/cliState', () => ({
    default: {
        basePath: undefined,
    },
}));
describe('fetchWithProxy', () => {
    beforeEach(() => {
        jest.clearAllMocks();
        jest.spyOn(global, 'fetch').mockImplementation();
        jest.mocked(undici_1.ProxyAgent).mockClear();
        jest.mocked(undici_1.setGlobalDispatcher).mockClear();
        delete process.env.HTTPS_PROXY;
        delete process.env.https_proxy;
        delete process.env.HTTP_PROXY;
        delete process.env.http_proxy;
    });
    afterEach(() => {
        jest.resetAllMocks();
    });
    it('should add version header to all requests', async () => {
        const url = 'https://example.com/api';
        await (0, fetch_1.fetchWithProxy)(url);
        expect(global.fetch).toHaveBeenCalledWith(url, expect.objectContaining({
            headers: expect.objectContaining({
                'x-promptfoo-version': constants_1.VERSION,
            }),
        }));
    });
    it('should handle URLs with basic auth credentials', async () => {
        const url = 'https://username:password@example.com/api';
        const options = { headers: { 'Content-Type': 'application/json' } };
        await (0, fetch_1.fetchWithProxy)(url, options);
        expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', expect.objectContaining({
            headers: {
                'Content-Type': 'application/json',
                Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should handle URLs without auth credentials', async () => {
        const url = 'https://example.com/api';
        const options = { headers: { 'Content-Type': 'application/json' } };
        await (0, fetch_1.fetchWithProxy)(url, options);
        expect(global.fetch).toHaveBeenCalledWith(url, expect.objectContaining({
            headers: {
                'Content-Type': 'application/json',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should handle invalid URLs gracefully', async () => {
        const invalidUrl = 'not-a-url';
        await (0, fetch_1.fetchWithProxy)(invalidUrl);
        expect(logger_1.default.debug).toHaveBeenCalledWith(expect.stringMatching(/URL parsing failed in fetchWithProxy: TypeError/));
        expect(global.fetch).toHaveBeenCalledWith(invalidUrl, expect.any(Object));
    });
    it('should preserve existing Authorization headers when no URL credentials', async () => {
        const url = 'https://example.com/api';
        const options = {
            headers: {
                Authorization: 'Bearer token123',
                'Content-Type': 'application/json',
            },
        };
        await (0, fetch_1.fetchWithProxy)(url, options);
        expect(global.fetch).toHaveBeenCalledWith(url, expect.objectContaining({
            headers: {
                Authorization: 'Bearer token123',
                'Content-Type': 'application/json',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should warn and prefer existing Authorization header over URL credentials', async () => {
        const url = 'https://username:password@example.com/api';
        const options = {
            headers: {
                Authorization: 'Bearer token123',
            },
        };
        await (0, fetch_1.fetchWithProxy)(url, options);
        expect(logger_1.default.warn).toHaveBeenCalledWith(expect.stringContaining('Both URL credentials and Authorization header present'));
        expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', expect.objectContaining({
            headers: {
                Authorization: 'Bearer token123',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should handle empty username or password in URL', async () => {
        const url = 'https://:password@example.com/api';
        await (0, fetch_1.fetchWithProxy)(url);
        expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', expect.objectContaining({
            headers: {
                Authorization: 'Basic OnBhc3N3b3Jk',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should handle URLs with only username', async () => {
        const url = 'https://username@example.com/api';
        await (0, fetch_1.fetchWithProxy)(url);
        expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', expect.objectContaining({
            headers: {
                Authorization: 'Basic dXNlcm5hbWU6',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should preserve existing headers when adding Authorization from URL credentials', async () => {
        const url = 'https://username:password@example.com/api';
        const options = {
            headers: {
                'Content-Type': 'application/json',
                'X-Custom-Header': 'value',
            },
        };
        await (0, fetch_1.fetchWithProxy)(url, options);
        expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', expect.objectContaining({
            headers: {
                'Content-Type': 'application/json',
                'X-Custom-Header': 'value',
                Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
                'x-promptfoo-version': constants_1.VERSION,
            },
        }));
    });
    it('should use custom CA certificate when PROMPTFOO_CA_CERT_PATH is set', async () => {
        const mockCertPath = path_1.default.normalize('/path/to/cert.pem');
        const mockCertContent = 'mock-cert-content';
        const mockProxyUrl = 'http://proxy.example.com';
        process.env.HTTPS_PROXY = mockProxyUrl;
        jest.mocked(envars_1.getEnvString).mockImplementation((key, defaultValue = '') => {
            if (key === 'PROMPTFOO_CA_CERT_PATH') {
                return mockCertPath;
            }
            return defaultValue;
        });
        jest.mocked(envars_1.getEnvBool).mockImplementation((key, defaultValue = false) => {
            if (key === 'PROMPTFOO_INSECURE_SSL') {
                return false;
            }
            return defaultValue;
        });
        jest.mocked(fs_1.default.readFileSync).mockReturnValue(mockCertContent);
        const mockFetch = jest.fn().mockResolvedValue(new Response());
        global.fetch = mockFetch;
        await (0, fetch_1.fetchWithProxy)('https://example.com');
        const actualPath = jest.mocked(fs_1.default.readFileSync).mock.calls[0][0];
        const normalizedActual = path_1.default.normalize(actualPath).replace(/^\w:/, '');
        const normalizedExpected = path_1.default.normalize(mockCertPath).replace(/^\w:/, '');
        expect(normalizedActual).toBe(normalizedExpected);
        expect(undici_1.ProxyAgent).toHaveBeenCalledWith({
            uri: mockProxyUrl,
            proxyTls: {
                ca: [mockCertContent],
                rejectUnauthorized: true,
            },
        });
        expect(undici_1.setGlobalDispatcher).toHaveBeenCalledWith(expect.any(undici_1.ProxyAgent));
    });
    it('should handle missing CA certificate file gracefully', async () => {
        const mockCertPath = path_1.default.normalize('/path/to/nonexistent.pem');
        const mockProxyUrl = 'http://proxy.example.com';
        process.env.HTTPS_PROXY = mockProxyUrl;
        jest.mocked(envars_1.getEnvString).mockImplementation((key, defaultValue = '') => {
            if (key === 'PROMPTFOO_CA_CERT_PATH') {
                return mockCertPath;
            }
            return defaultValue;
        });
        jest.mocked(fs_1.default.readFileSync).mockImplementation(() => {
            throw new Error('File not found');
        });
        const mockFetch = jest.fn().mockResolvedValue(new Response());
        global.fetch = mockFetch;
        await (0, fetch_1.fetchWithProxy)('https://example.com');
        const actualPath = jest.mocked(fs_1.default.readFileSync).mock.calls[0][0];
        const normalizedActual = path_1.default.normalize(actualPath).replace(/^\w:/, '');
        const normalizedExpected = path_1.default.normalize(mockCertPath).replace(/^\w:/, '');
        expect(normalizedActual).toBe(normalizedExpected);
        expect(undici_1.ProxyAgent).toHaveBeenCalledWith({
            uri: mockProxyUrl,
            proxyTls: {
                rejectUnauthorized: true,
            },
        });
        expect(undici_1.setGlobalDispatcher).toHaveBeenCalledWith(expect.any(undici_1.ProxyAgent));
    });
    it('should disable SSL verification when PROMPTFOO_INSECURE_SSL is true', async () => {
        const mockProxyUrl = 'http://proxy.example.com';
        process.env.HTTPS_PROXY = mockProxyUrl;
        jest
            .mocked(envars_1.getEnvString)
            .mockImplementation((key, defaultValue = '') => defaultValue);
        jest.mocked(envars_1.getEnvBool).mockImplementation((key, defaultValue = false) => {
            if (key === 'PROMPTFOO_INSECURE_SSL') {
                return true;
            }
            return defaultValue;
        });
        const mockFetch = jest.fn().mockResolvedValue(new Response());
        global.fetch = mockFetch;
        await (0, fetch_1.fetchWithProxy)('https://example.com');
        expect(undici_1.ProxyAgent).toHaveBeenCalledWith({
            uri: mockProxyUrl,
            proxyTls: {
                rejectUnauthorized: false,
            },
        });
        expect(undici_1.setGlobalDispatcher).toHaveBeenCalledWith(expect.any(undici_1.ProxyAgent));
    });
    it('should resolve CA certificate path relative to basePath when available', async () => {
        const mockBasePath = path_1.default.normalize('/base/path');
        const mockCertPath = 'certs/cert.pem';
        const mockCertContent = 'mock-cert-content';
        const mockProxyUrl = 'http://proxy.example.com';
        process.env.HTTPS_PROXY = mockProxyUrl;
        // Update basePath in cliState
        cliState_1.default.basePath = mockBasePath;
        jest.mocked(envars_1.getEnvString).mockImplementation((key, defaultValue = '') => {
            if (key === 'PROMPTFOO_CA_CERT_PATH') {
                return mockCertPath;
            }
            return defaultValue;
        });
        jest.mocked(envars_1.getEnvBool).mockImplementation((key, defaultValue = false) => {
            if (key === 'PROMPTFOO_INSECURE_SSL') {
                return false;
            }
            return defaultValue;
        });
        jest.mocked(fs_1.default.readFileSync).mockReturnValue(mockCertContent);
        const mockFetch = jest.fn().mockResolvedValue(new Response());
        global.fetch = mockFetch;
        await (0, fetch_1.fetchWithProxy)('https://example.com');
        const expectedPath = path_1.default.normalize(path_1.default.join(mockBasePath, mockCertPath));
        const actualPath = jest.mocked(fs_1.default.readFileSync).mock.calls[0][0];
        const normalizedActual = path_1.default.normalize(actualPath).replace(/^\w:/, '');
        const normalizedExpected = path_1.default.normalize(expectedPath).replace(/^\w:/, '');
        const normalizedBasePath = path_1.default.normalize(mockBasePath).replace(/^\w:/, '');
        const normalizedCertPath = path_1.default.normalize(mockCertPath);
        expect(normalizedActual).toBe(normalizedExpected);
        expect(normalizedActual).toContain(normalizedBasePath);
        expect(normalizedActual).toContain(normalizedCertPath);
        expect(undici_1.ProxyAgent).toHaveBeenCalledWith({
            uri: mockProxyUrl,
            proxyTls: {
                ca: [mockCertContent],
                rejectUnauthorized: true,
            },
        });
        expect(undici_1.setGlobalDispatcher).toHaveBeenCalledWith(expect.any(undici_1.ProxyAgent));
        // Reset basePath
        cliState_1.default.basePath = undefined;
    });
    it('should not create ProxyAgent when no proxy URL is found', async () => {
        await (0, fetch_1.fetchWithProxy)('https://example.com');
        expect(undici_1.ProxyAgent).not.toHaveBeenCalled();
        expect(undici_1.setGlobalDispatcher).not.toHaveBeenCalled();
    });
    it('should use proxy URL from environment variables in order of precedence', async () => {
        const mockProxyUrls = {
            HTTPS_PROXY: 'http://https-proxy.example.com',
            https_proxy: 'http://https-proxy-lower.example.com',
            HTTP_PROXY: 'http://http-proxy.example.com',
            http_proxy: 'http://http-proxy-lower.example.com',
        };
        const allProxyVars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'];
        const testCases = [
            {
                env: { https_proxy: mockProxyUrls.https_proxy },
                expected: { url: mockProxyUrls.https_proxy },
            },
            {
                env: { HTTP_PROXY: mockProxyUrls.HTTP_PROXY },
                expected: { url: mockProxyUrls.HTTP_PROXY },
            },
            {
                env: { http_proxy: mockProxyUrls.http_proxy },
                expected: { url: mockProxyUrls.http_proxy },
            },
        ];
        for (const testCase of testCases) {
            jest.clearAllMocks();
            allProxyVars.forEach((key) => {
                delete process.env[key];
            });
            const existingProxyVars = allProxyVars.filter((key) => process.env[key]);
            if (existingProxyVars.length > 0) {
                console.warn('Found unexpected proxy variables:', existingProxyVars);
            }
            Object.entries(testCase.env).forEach(([key, value]) => {
                process.env[key] = value;
            });
            await (0, fetch_1.fetchWithProxy)('https://example.com');
            expect(undici_1.ProxyAgent).toHaveBeenCalledWith({
                uri: testCase.expected.url,
                proxyTls: {
                    rejectUnauthorized: !(0, envars_1.getEnvBool)('PROMPTFOO_INSECURE_SSL', false),
                },
            });
            expect(undici_1.setGlobalDispatcher).toHaveBeenCalledWith(expect.any(undici_1.ProxyAgent));
            const debugCalls = jest.mocked(logger_1.default.debug).mock.calls;
            expect(debugCalls).toEqual(expect.arrayContaining([
                [
                    expect.stringMatching(new RegExp(`Found proxy configuration in .*https?_proxy.*: ${testCase.expected.url}`, 'i')),
                ],
                [`Using proxy: ${testCase.expected.url}`],
            ]));
            allProxyVars.forEach((key) => {
                delete process.env[key];
            });
        }
    });
});
describe('fetchWithTimeout', () => {
    beforeEach(() => {
        jest.useFakeTimers();
        jest.spyOn(global, 'fetch').mockImplementation();
    });
    afterEach(() => {
        jest.useRealTimers();
    });
    it('should resolve when fetch completes before timeout', async () => {
        const mockResponse = (0, utils_1.createMockResponse)({ ok: true });
        jest.mocked(global.fetch).mockImplementationOnce(() => Promise.resolve(mockResponse));
        const fetchPromise = (0, fetch_1.fetchWithTimeout)('https://example.com', {}, 5000);
        await expect(fetchPromise).resolves.toBe(mockResponse);
    });
    it('should reject when request times out', async () => {
        jest
            .mocked(global.fetch)
            .mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 6000)));
        const fetchPromise = (0, fetch_1.fetchWithTimeout)('https://example.com', {}, 5000);
        jest.advanceTimersByTime(5000);
        await expect(fetchPromise).rejects.toThrow('Request timed out after 5000 ms');
    });
});
describe('isRateLimited', () => {
    it('should detect standard rate limit headers', () => {
        const response = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'X-RateLimit-Remaining': '0',
            }),
            status: 200,
        });
        expect((0, fetch_1.isRateLimited)(response)).toBe(true);
    });
    it('should detect 429 status code', () => {
        const response = (0, utils_1.createMockResponse)({
            status: 429,
        });
        expect((0, fetch_1.isRateLimited)(response)).toBe(true);
    });
    it('should detect OpenAI specific rate limits', () => {
        const response = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'x-ratelimit-remaining-requests': '0',
            }),
        });
        expect((0, fetch_1.isRateLimited)(response)).toBe(true);
        const tokenResponse = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'x-ratelimit-remaining-tokens': '0',
            }),
        });
        expect((0, fetch_1.isRateLimited)(tokenResponse)).toBe(true);
    });
    it('should return false when not rate limited', () => {
        const response = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'X-RateLimit-Remaining': '10',
            }),
            status: 200,
        });
        expect((0, fetch_1.isRateLimited)(response)).toBe(false);
    });
});
describe('handleRateLimit', () => {
    beforeEach(() => {
        jest.useFakeTimers();
        jest.mocked(time_1.sleep).mockClear();
    });
    afterEach(() => {
        jest.useRealTimers();
    });
    it('should handle OpenAI reset headers', async () => {
        const response = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'x-ratelimit-reset-requests': '5',
            }),
        });
        const promise = (0, fetch_1.handleRateLimit)(response);
        jest.advanceTimersByTime(5000);
        await promise;
        expect(logger_1.default.debug).toHaveBeenCalledWith('Rate limited, waiting 5000ms before retry');
    });
    it('should handle standard rate limit reset headers', async () => {
        const futureTime = Math.floor((Date.now() + 5000) / 1000);
        const response = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'X-RateLimit-Reset': futureTime.toString(),
            }),
        });
        const promise = (0, fetch_1.handleRateLimit)(response);
        await promise;
        expect(logger_1.default.debug).toHaveBeenCalledWith(expect.stringMatching(/Rate limited, waiting \d+ms before retry/));
    });
    it('should handle Retry-After headers', async () => {
        const response = (0, utils_1.createMockResponse)({
            headers: new Headers({
                'Retry-After': '5',
            }),
        });
        const promise = (0, fetch_1.handleRateLimit)(response);
        jest.advanceTimersByTime(5000);
        await promise;
        expect(logger_1.default.debug).toHaveBeenCalledWith('Rate limited, waiting 5000ms before retry');
    });
    it('should use default wait time when no headers present', async () => {
        const response = (0, utils_1.createMockResponse)();
        const promise = (0, fetch_1.handleRateLimit)(response);
        jest.advanceTimersByTime(60000);
        await promise;
        expect(logger_1.default.debug).toHaveBeenCalledWith('Rate limited, waiting 60000ms before retry');
    });
});
describe('fetchWithRetries', () => {
    beforeEach(() => {
        jest.mocked(time_1.sleep).mockClear();
        jest.spyOn(global, 'fetch').mockImplementation();
        jest.clearAllMocks();
    });
    it('should make exactly one attempt when retries is 0', async () => {
        const successResponse = (0, utils_1.createMockResponse)();
        jest.mocked(global.fetch).mockResolvedValueOnce(successResponse);
        await (0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, 0);
        expect(global.fetch).toHaveBeenCalledTimes(1);
        expect(time_1.sleep).not.toHaveBeenCalled();
    });
    it('should handle negative retry values by treating them as 0', async () => {
        const successResponse = (0, utils_1.createMockResponse)();
        jest.mocked(global.fetch).mockResolvedValueOnce(successResponse);
        await (0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, -1);
        expect(global.fetch).toHaveBeenCalledTimes(1);
        expect(time_1.sleep).not.toHaveBeenCalled();
    });
    it('should make retries+1 total attempts', async () => {
        jest.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
        await expect((0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, 2)).rejects.toThrow('Request failed after 2 retries: Network error');
        expect(global.fetch).toHaveBeenCalledTimes(3); // Initial attempt + 2 retries
        expect(time_1.sleep).toHaveBeenCalledTimes(2); // Should sleep between attempts, but not after last attempt
    });
    it('should not sleep after the final attempt', async () => {
        jest.mocked(global.fetch).mockRejectedValue(new Error('Network error'));
        await expect((0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, 1)).rejects.toThrow('Request failed after 1 retries: Network error');
        expect(global.fetch).toHaveBeenCalledTimes(2); // Initial attempt + 1 retry
        expect(time_1.sleep).toHaveBeenCalledTimes(1); // Should only sleep once between attempts
    });
    it('should handle 5XX errors when PROMPTFOO_RETRY_5XX is true', async () => {
        jest.mocked(envars_1.getEnvBool).mockImplementation((key) => {
            if (key === 'PROMPTFOO_RETRY_5XX') {
                return true;
            }
            return false;
        });
        const errorResponse = (0, utils_1.createMockResponse)({
            status: 502,
            statusText: 'Bad Gateway',
        });
        const successResponse = (0, utils_1.createMockResponse)();
        const mockFetch = jest
            .fn()
            .mockResolvedValueOnce(errorResponse)
            .mockResolvedValueOnce(successResponse);
        global.fetch = mockFetch;
        await (0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, 2);
        expect(mockFetch).toHaveBeenCalledTimes(2);
        expect(time_1.sleep).toHaveBeenCalledTimes(1);
    });
    it('should handle rate limits with proper backoff', async () => {
        const rateLimitedResponse = (0, utils_1.createMockResponse)({
            status: 429,
            headers: new Headers({
                'Retry-After': '1',
            }),
        });
        const successResponse = (0, utils_1.createMockResponse)();
        const mockFetch = jest
            .fn()
            .mockResolvedValueOnce(rateLimitedResponse)
            .mockResolvedValueOnce(successResponse);
        global.fetch = mockFetch;
        await (0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, 2);
        expect(mockFetch).toHaveBeenCalledTimes(2);
        expect(logger_1.default.debug).toHaveBeenCalledWith(expect.stringContaining('Rate limited on URL'));
        expect(time_1.sleep).toHaveBeenCalledTimes(1);
    });
    it('should respect maximum retry count', async () => {
        const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
        global.fetch = mockFetch;
        await expect((0, fetch_1.fetchWithRetries)('https://example.com', {}, 1000, 2)).rejects.toThrow('Request failed after 2 retries: Network error');
        expect(mockFetch).toHaveBeenCalledTimes(3); // Initial attempt + 2 retries
        expect(time_1.sleep).toHaveBeenCalledTimes(2);
    });
});
//# sourceMappingURL=fetch.test.js.map