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

477 lines
15 KiB
TypeScript

import fs from 'fs';
import * as fsp from 'fs/promises';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { fetchWithCache } from '../../src/cache';
import { HttpProvider } from '../../src/providers/http';
// Mock dependencies
vi.mock('../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithCache: vi.fn(),
};
});
vi.mock('undici', async (importOriginal) => {
const actual = await importOriginal<typeof import('undici')>();
return {
...actual,
Agent: vi.fn().mockImplementation(function (options) {
// createHttpsAgent composes the decompress interceptor onto the Agent,
// so the mock instance needs a compose() that returns itself — that
// keeps the existing `dispatcher: 'mock-agent'` assertions valid.
const agent: any = { options, dispatcher: 'mock-agent' };
agent.compose = vi.fn(() => agent);
return agent;
}),
interceptors: {
...actual.interceptors,
decompress: vi.fn(() => ({ name: 'decompress' })),
},
};
});
// Hoisted mock for fs.readFileSync
const mockReadFileSync = vi.hoisted(() => vi.fn());
// Mock fs module
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
readFileSync: mockReadFileSync,
},
readFileSync: mockReadFileSync,
};
});
// Wrap the sync mock so fs/promises.readFile returns a real Promise, matching
// the actual API contract. (await still unwraps non-Promise values, but tests
// for Promise-aware callers like Promise.all need the real shape.)
vi.mock('fs/promises', () => {
const readFile = vi.fn(async (...args: any[]) => mockReadFileSync(...args));
return { default: { readFile }, readFile };
});
describe('HttpProvider with TLS Configuration', () => {
const mockFetchWithCache = vi.mocked(fetchWithCache);
beforeEach(() => {
vi.clearAllMocks();
mockReadFileSync.mockReset();
vi.mocked(fsp.readFile)
.mockReset()
.mockImplementation(async (...args: any[]) => {
return mockReadFileSync(...args);
});
// Setup default mock response
mockFetchWithCache.mockResolvedValue({
data: JSON.stringify({ result: 'success' }),
cached: false,
status: 200,
statusText: 'OK',
headers: {},
});
});
describe('TLS certificate configuration', () => {
it('should create HTTPS agent when TLS config is provided', async () => {
// Mock file reads before creating provider
(fs.readFileSync as Mock)
.mockReturnValueOnce('CA_CERT_CONTENT')
.mockReturnValueOnce('CLIENT_CERT_CONTENT')
.mockReturnValueOnce('PRIVATE_KEY_CONTENT');
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
caPath: '/path/to/ca.pem',
certPath: '/path/to/cert.pem',
keyPath: '/path/to/key.pem',
rejectUnauthorized: true,
},
},
});
await provider.callApi('test prompt');
// Verify that fetchWithCache was called with dispatcher option
expect(mockFetchWithCache).toHaveBeenCalledWith(
'https://api.example.com',
expect.objectContaining({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: 'test prompt' }),
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
// Verify files were read
expect(mockReadFileSync).toHaveBeenCalledTimes(3);
});
it('should start independent TLS file reads in parallel', async () => {
const pendingReads = new Map<string, (value: string) => void>();
vi.mocked(fsp.readFile).mockImplementation(
(filePath: any) =>
new Promise((resolve) => {
pendingReads.set(String(filePath), resolve as (value: string) => void);
}) as ReturnType<typeof fsp.readFile>,
);
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
caPath: '/path/to/ca.pem',
certPath: '/path/to/cert.pem',
keyPath: '/path/to/key.pem',
},
},
});
const callPromise = provider.callApi('test prompt');
await vi.waitFor(() => {
expect(fsp.readFile).toHaveBeenCalledTimes(3);
});
expect([...pendingReads.keys()]).toEqual([
'/path/to/ca.pem',
'/path/to/cert.pem',
'/path/to/key.pem',
]);
pendingReads.get('/path/to/ca.pem')?.('CA_CERT_CONTENT');
pendingReads.get('/path/to/cert.pem')?.('CLIENT_CERT_CONTENT');
pendingReads.get('/path/to/key.pem')?.('PRIVATE_KEY_CONTENT');
await callPromise;
});
it('should support inline certificates', async () => {
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
ca: '-----BEGIN CERTIFICATE-----\nCA_CONTENT\n-----END CERTIFICATE-----',
cert: '-----BEGIN CERTIFICATE-----\nCERT_CONTENT\n-----END CERTIFICATE-----',
key: '-----BEGIN PRIVATE KEY-----\nKEY_CONTENT\n-----END PRIVATE KEY-----',
},
},
});
await provider.callApi('test prompt');
// Should not read from files when inline certs are provided
expect(mockReadFileSync).not.toHaveBeenCalled();
// Verify dispatcher was included
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
}),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should support PFX certificates', async () => {
// Mock PFX file read (returns Buffer)
mockReadFileSync.mockReturnValue(Buffer.from('PFX_CONTENT'));
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
pfxPath: '/path/to/cert.pfx',
passphrase: 'secret',
},
},
});
await provider.callApi('test prompt');
// Verify PFX file was read
expect(mockReadFileSync).toHaveBeenCalledWith(expect.stringContaining('cert.pfx'));
// Verify dispatcher was included
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
}),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should support inline PFX certificates as base64-encoded string', async () => {
// Create a base64-encoded PFX content (simulating a real PFX file)
const pfxBuffer = Buffer.from('PFX_BINARY_CONTENT');
const base64Pfx = pfxBuffer.toString('base64');
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
pfx: base64Pfx,
passphrase: 'secret',
},
},
});
await provider.callApi('test prompt');
// Should not read from files when inline PFX is provided
expect(mockReadFileSync).not.toHaveBeenCalled();
// Verify dispatcher was included
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
}),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should support inline PFX certificates as Buffer', async () => {
const pfxBuffer = Buffer.from('PFX_BINARY_CONTENT');
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
pfx: pfxBuffer,
passphrase: 'secret',
},
},
});
await provider.callApi('test prompt');
// Should not read from files when inline PFX is provided
expect(mockReadFileSync).not.toHaveBeenCalled();
// Verify dispatcher was included
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
}),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should support advanced TLS options', async () => {
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'GET',
tls: {
rejectUnauthorized: false,
servername: 'api.example.com',
ciphers: 'TLS_AES_256_GCM_SHA384',
minVersion: 'TLSv1.2',
maxVersion: 'TLSv1.3',
},
},
});
await provider.callApi('test prompt');
// Verify dispatcher was included with the configuration
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
options: expect.objectContaining({
connect: expect.objectContaining({
rejectUnauthorized: false,
servername: 'api.example.com',
ciphers: 'TLS_AES_256_GCM_SHA384',
}),
}),
}),
}),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should work without TLS configuration', async () => {
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
},
});
await provider.callApi('test prompt');
// Verify that fetchWithCache was called without dispatcher
const callArgs = mockFetchWithCache.mock.calls[0];
expect(callArgs[1]).not.toHaveProperty('dispatcher');
});
it('should validate cert/key pairs', () => {
// This should throw because cert is provided without key
expect(() => {
new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
tls: {
certPath: '/path/to/cert.pem',
// Missing keyPath - should fail validation
},
},
});
}).toThrow('Both certificate and key must be provided');
});
it('should work with raw request and TLS', async () => {
mockReadFileSync.mockReturnValue('CA_CERT_CONTENT');
const provider = new HttpProvider('https://api.example.com', {
config: {
request:
'POST /endpoint HTTP/1.1\nHost: api.example.com\nContent-Type: application/json\n\n{"prompt": "{{prompt}}"}',
tls: {
caPath: '/path/to/ca.pem',
},
},
});
await provider.callApi('test prompt');
// Verify dispatcher was included for raw request
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.stringContaining('api.example.com'),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('composes the decompress interceptor onto the TLS dispatcher', async () => {
// Regression: createHttpsAgent returned a bare Agent with no decompress
// interceptor, so on Node 26 (where undici no longer auto-decompresses)
// a gzip/br response over an mTLS dispatcher came back as raw bytes.
const undici = await import('undici');
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'GET',
tls: { rejectUnauthorized: false },
},
});
await provider.callApi('test prompt');
expect(undici.interceptors.decompress).toHaveBeenCalledWith({ skipErrorResponses: false });
const agentInstance = vi.mocked(undici.Agent).mock.results[0].value as any;
expect(agentInstance.compose).toHaveBeenCalledWith({ name: 'decompress' });
});
});
describe('TLS with signature auth', () => {
it('should support both TLS and signature authentication', async () => {
// Mock crypto.createSign to avoid actual signature generation
const mockSign = {
update: vi.fn(),
end: vi.fn(),
sign: vi.fn().mockReturnValue(Buffer.from('mock-signature')),
};
vi.spyOn(require('crypto'), 'createSign').mockReturnValue(mockSign as any);
mockReadFileSync.mockReturnValue('CA_CERT_CONTENT');
const provider = new HttpProvider('https://api.example.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: '{{prompt}}' },
tls: {
caPath: '/path/to/ca.pem',
},
signatureAuth: {
type: 'pem',
privateKey:
'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7W8rjA3yCLAVz\nKEY_FOR_TESTING\n-----END PRIVATE KEY-----',
signatureAlgorithm: 'SHA256',
},
},
});
await provider.callApi('test prompt');
// Should have both dispatcher (for TLS) and signature in body
expect(mockFetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
dispatcher: expect.objectContaining({
dispatcher: 'mock-agent',
}),
headers: expect.any(Object),
}),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
});
});