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

2848 lines
83 KiB
TypeScript

// Authentication tests: RSA signatures, JKS keystores, OAuth token refresh, file-based auth, bearer/API key auth.
import './setup';
import crypto from 'crypto';
import fsPromises from 'fs/promises';
import path from 'path';
import dedent from 'dedent';
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest';
import { fetchWithCache } from '../../../src/cache';
import { importModule } from '../../../src/esm';
import logger from '../../../src/logger';
import { HttpProvider } from '../../../src/providers/http';
import { runPython } from '../../../src/python/pythonUtils';
import { TOKEN_REFRESH_BUFFER_MS } from '../../../src/util/oauth';
import { createDeferred, mockProcessEnv } from '../../util/utils';
const fsPromisesMocks = vi.hoisted(() => ({
actualReadFile: undefined as typeof import('fs/promises').readFile | undefined,
readFile: vi.fn(),
}));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
fsPromisesMocks.actualReadFile = actual.readFile;
return {
...actual,
default: {
...actual,
readFile: fsPromisesMocks.readFile,
},
readFile: fsPromisesMocks.readFile,
};
});
describe('RSA signature authentication', () => {
let mockPrivateKey: string;
let mockSign: MockInstance;
let mockUpdate: MockInstance;
let mockEnd: MockInstance;
beforeEach(() => {
mockPrivateKey = '-----BEGIN PRIVATE KEY-----\nMOCK_KEY\n-----END PRIVATE KEY-----';
vi.mocked(fsPromises.readFile).mockImplementation((async (filePath, options) => {
if (filePath.toString() === '/path/to/key.pem') {
return mockPrivateKey;
}
return fsPromisesMocks.actualReadFile!(filePath as any, options as any);
}) as typeof fsPromises.readFile);
mockUpdate = vi.fn();
mockEnd = vi.fn();
mockSign = vi.fn().mockReturnValue(Buffer.from('mocksignature'));
const mockSignObject = {
update: mockUpdate,
end: mockEnd,
sign: mockSign,
};
vi.spyOn(crypto, 'createSign').mockReturnValue(mockSignObject as any);
vi.spyOn(Date, 'now').mockReturnValue(1000); // Mock timestamp
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should generate and include signature in vars', async () => {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
privateKeyPath: '/path/to/key.pem',
signatureValidityMs: 300000, // 5 minutes
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
// Verify signature generation with specific data
expect(fsPromises.readFile).toHaveBeenCalledWith('/path/to/key.pem', 'utf8');
expect(crypto.createSign).toHaveBeenCalledWith('SHA256');
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockEnd).toHaveBeenCalledTimes(1);
expect(mockSign).toHaveBeenCalledWith(mockPrivateKey);
});
it('should reuse cached signature when within validity period', async () => {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
privateKeyPath: '/path/to/key.pem',
signatureValidityMs: 300000,
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
// First call should generate signature
await provider.callApi('test');
expect(crypto.createSign).toHaveBeenCalledTimes(1);
// Second call within validity period should reuse signature
vi.spyOn(Date, 'now').mockReturnValue(2000); // Still within validity period
await provider.callApi('test');
expect(crypto.createSign).toHaveBeenCalledTimes(1); // Should not be called again
});
it('should regenerate signature at the default refresh buffer boundary', async () => {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
privateKeyPath: '/path/to/key.pem',
signatureValidityMs: 300000,
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
// First call should generate signature
await provider.callApi('test');
expect(crypto.createSign).toHaveBeenCalledTimes(1);
// The default refresh buffer is 10%, so a five-minute signature refreshes at 4.5 minutes.
vi.mocked(Date.now).mockReturnValue(270999);
await provider.callApi('test');
expect(crypto.createSign).toHaveBeenCalledTimes(1);
vi.mocked(Date.now).mockReturnValue(271000);
await provider.callApi('test');
expect(crypto.createSign).toHaveBeenCalledTimes(2);
});
it('should use custom signature data template', async () => {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
privateKeyPath: '/path/to/key.pem',
signatureValidityMs: 300000,
signatureDataTemplate: 'custom-{{signatureTimestamp}}',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
// Verify signature generation with custom template
expect(crypto.createSign).toHaveBeenCalledWith('SHA256');
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith('custom-1000'); // Custom template
expect(mockEnd).toHaveBeenCalledTimes(1);
expect(mockSign).toHaveBeenCalledWith(mockPrivateKey);
});
it('should support using privateKey directly instead of privateKeyPath', async () => {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
privateKey: mockPrivateKey,
signatureValidityMs: 300000,
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
// Verify signature generation using privateKey directly
const privateKeyFileReads = vi
.mocked(fsPromises.readFile)
.mock.calls.filter(([filePath]) => filePath === '/path/to/key.pem');
expect(privateKeyFileReads).toHaveLength(0);
expect(crypto.createSign).toHaveBeenCalledWith('SHA256');
expect(mockSign).toHaveBeenCalledWith(mockPrivateKey);
});
it('should warn when vars already contain signatureTimestamp', async () => {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
privateKey: mockPrivateKey,
signatureValidityMs: 300000,
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const mockWarn = vi.spyOn(logger, 'warn');
const timestampWarning =
'[HTTP Provider Auth]: `signatureTimestamp` is already defined in vars and will be overwritten';
try {
await provider.callApi('test', {
prompt: { raw: 'test', label: 'test' },
vars: {
signatureTimestamp: 'existing-timestamp',
},
});
expect(mockWarn).toHaveBeenCalledWith(timestampWarning);
} finally {
mockWarn.mockRestore();
}
});
it('should use JKS keystore password from environment variable when config password not provided', async () => {
// Get the mocked JKS module
const jksMock = vi.mocked(await import('jks-js'));
jksMock.toPem.mockReturnValue({
client: {
key: mockPrivateKey,
},
});
// Mock fs/promises.readFile to return mock keystore data
vi.mocked(fsPromises.readFile).mockResolvedValue(Buffer.from('mock-keystore-data'));
const restoreEnv = mockProcessEnv({
PROMPTFOO_JKS_PASSWORD: 'env-password',
});
try {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
type: 'jks',
keystorePath: '/path/to/keystore.jks',
// keystorePassword not provided - should use env var
keyAlias: 'client',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
// Verify JKS module was called with environment variable password
expect(jksMock.toPem).toHaveBeenCalledWith(expect.anything(), 'env-password');
} finally {
restoreEnv();
}
});
it('should prioritize config keystorePassword over environment variable', async () => {
// Get the mocked JKS module
const jksMock = vi.mocked(await import('jks-js'));
jksMock.toPem.mockReturnValue({
client: {
key: mockPrivateKey,
},
});
// Mock fs/promises.readFile to return mock keystore data
vi.mocked(fsPromises.readFile).mockResolvedValue(Buffer.from('mock-keystore-data'));
const restoreEnv = mockProcessEnv({
PROMPTFOO_JKS_PASSWORD: 'env-password',
});
try {
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
type: 'jks',
keystorePath: '/path/to/keystore.jks',
keystorePassword: 'config-password', // This should take precedence
keyAlias: 'client',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
// Verify JKS module was called with config password, not env var
expect(jksMock.toPem).toHaveBeenCalledWith(expect.any(Buffer), 'config-password');
} finally {
restoreEnv();
}
});
it('should throw error when neither config password nor environment variable is provided for JKS', async () => {
// Get the mocked JKS module
const jksMock = vi.mocked(await import('jks-js'));
jksMock.toPem.mockImplementation(function () {
throw new Error('Should not be called');
});
// Mock fs/promises.readFile to return mock keystore data
vi.mocked(fsPromises.readFile).mockResolvedValue(Buffer.from('mock-keystore-data'));
const provider = new HttpProvider('http://example.com', {
config: {
method: 'POST',
body: { key: 'value' },
signatureAuth: {
type: 'jks',
keystorePath: '/path/to/keystore.jks',
// keystorePassword not provided and env var is empty
keyAlias: 'client',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const restoreEnv = mockProcessEnv({
PROMPTFOO_JKS_PASSWORD: undefined,
});
try {
expect(process.env.PROMPTFOO_JKS_PASSWORD).toBeUndefined();
await expect(provider.callApi('test')).rejects.toThrow(
'JKS keystore password is required. Provide it via config keystorePassword/certificatePassword or PROMPTFOO_JKS_PASSWORD environment variable',
);
} finally {
restoreEnv();
}
});
});
describe('HttpProvider - OAuth Token Refresh Deduplication', () => {
const mockUrl = 'http://example.com/api';
const tokenUrl = 'https://auth.example.com/oauth/token';
let tokenRefreshCallCount: number;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchWithCache).mockReset();
tokenRefreshCallCount = 0;
});
it('should deduplicate concurrent token refresh requests', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer {{token}}',
},
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
// Mock token refresh response (delayed to simulate network latency)
const tokenResponse = {
data: JSON.stringify({
access_token: 'new-access-token-123',
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
// Mock API response
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
// Track token refresh calls
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
tokenRefreshCallCount++;
// Yield so the other concurrent callers register on the in-flight
// refresh promise before this one resolves.
await Promise.resolve();
return tokenResponse;
}
return apiResponse;
});
// Make 5 concurrent API calls
const promises = Array.from({ length: 5 }, () => provider.callApi('test prompt'));
await Promise.all(promises);
// Should only make 1 token refresh request despite 5 concurrent calls
expect(tokenRefreshCallCount).toBe(1);
// Verify token refresh was called exactly once
const tokenRefreshCalls = vi
.mocked(fetchWithCache)
.mock.calls.filter((call) => call[0] === tokenUrl);
expect(tokenRefreshCalls).toHaveLength(1);
});
it('should not reuse OAuth tokens across different rendered credentials', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: '{{ clientSecret }}',
},
},
});
const apiAuthHeaders: string[] = [];
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo, options: any) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
const body = String(options.body);
const token = body.includes('secret-a') ? 'token-a' : 'token-b';
return {
data: JSON.stringify({ access_token: token, expires_in: 3600 }),
status: 200,
statusText: 'OK',
cached: false,
};
}
apiAuthHeaders.push(options.headers.authorization);
return {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: { clientSecret: 'secret-a' },
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: { clientSecret: 'secret-b' },
});
const tokenRefreshCalls = vi
.mocked(fetchWithCache)
.mock.calls.filter((call) => call[0] === tokenUrl);
expect(tokenRefreshCalls).toHaveLength(2);
expect(apiAuthHeaders).toEqual(['Bearer token-a', 'Bearer token-b']);
const cacheKeys = Array.from((provider as any).authTokenCache.keys());
expect(cacheKeys).toHaveLength(2);
expect(cacheKeys).toEqual([
expect.stringMatching(/^oauth:[a-f0-9]{64}$/),
expect.stringMatching(/^oauth:[a-f0-9]{64}$/),
]);
expect(cacheKeys.join('\n')).not.toContain('secret-a');
expect(cacheKeys.join('\n')).not.toContain('secret-b');
expect(cacheKeys.join('\n')).not.toContain('test-client-id');
});
it('should return refreshed OAuth tokens when concurrent keys exceed the cache limit', async () => {
let releaseRefreshes!: () => void;
const refreshesBlocked = new Promise<void>((resolve) => {
releaseRefreshes = resolve;
});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: '{{ clientSecret }}',
},
},
});
const apiAuthHeaders = new Set<string>();
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo, options: any) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
tokenRefreshCallCount += 1;
await refreshesBlocked;
const clientSecret = new URLSearchParams(String(options.body)).get('client_secret');
return {
data: JSON.stringify({
access_token: `token-${clientSecret}`,
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
}
apiAuthHeaders.add(options.headers.authorization);
return {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
});
const requests = Array.from({ length: 257 }, (_, index) =>
provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: { clientSecret: `secret-${index}` },
}),
);
await vi.waitFor(() => expect(tokenRefreshCallCount).toBe(257));
releaseRefreshes();
await expect(Promise.all(requests)).resolves.toHaveLength(257);
expect(apiAuthHeaders.size).toBe(257);
expect((provider as any).authTokenCache.size).toBe(256);
});
it('should not cross-use OAuth tokens across concurrent raw requests', async () => {
let releaseFirstTransform!: () => void;
let resolveFirstTransformStarted!: () => void;
const firstTransformStarted = new Promise<void>((resolve) => {
resolveFirstTransformStarted = resolve;
});
const rawRequest = dedent`
POST /api HTTP/1.1
Host: example.com
Content-Type: application/json
{"prompt": "{{prompt}}"}
`;
const provider = new HttpProvider('https', {
config: {
request: rawRequest,
transformRequest: async (prompt: string, vars: Record<string, any>) => {
if (vars.clientSecret === 'secret-a') {
resolveFirstTransformStarted();
await new Promise<void>((resolve) => {
releaseFirstTransform = resolve;
});
}
return prompt;
},
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: '{{ clientSecret }}',
},
},
});
const apiAuthByPrompt: Record<string, string> = {};
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo, options: any) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
const body = String(options.body);
const token = body.includes('secret-a') ? 'token-a' : 'token-b';
return {
data: JSON.stringify({ access_token: token, expires_in: 3600 }),
status: 200,
statusText: 'OK',
cached: false,
};
}
const requestBody = JSON.parse(options.body);
apiAuthByPrompt[requestBody.prompt] = options.headers.authorization;
return {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
});
const firstCall = provider.callApi('first', {
prompt: { raw: 'first', label: 'first' },
vars: { clientSecret: 'secret-a' },
});
await firstTransformStarted;
await provider.callApi('second', {
prompt: { raw: 'second', label: 'second' },
vars: { clientSecret: 'secret-b' },
});
releaseFirstTransform();
await firstCall;
expect(apiAuthByPrompt).toEqual({
first: 'Bearer token-a',
second: 'Bearer token-b',
});
});
it('should use the same token for all concurrent API calls', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const expectedToken = 'shared-token-456';
const tokenResponse = {
data: JSON.stringify({
access_token: expectedToken,
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
// Yield so concurrent callers register on the in-flight refresh.
await Promise.resolve();
return tokenResponse;
}
return apiResponse;
});
// Make 3 concurrent API calls
await Promise.all([
provider.callApi('test 1'),
provider.callApi('test 2'),
provider.callApi('test 3'),
]);
// Verify all API calls used the same token
const apiCalls = vi.mocked(fetchWithCache).mock.calls.filter((call) => call[0] === mockUrl);
expect(apiCalls.length).toBeGreaterThan(0);
apiCalls.forEach((call) => {
const headers = call[1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe(`Bearer ${expectedToken}`);
});
});
it('should retry token refresh if the in-progress refresh fails', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const failingTokenResponse = {
data: JSON.stringify({ error: 'invalid_client' }),
status: 401,
statusText: 'Unauthorized',
cached: false,
};
const successTokenResponse = {
data: JSON.stringify({
access_token: 'retry-success-token',
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
const firstRefreshStarted = createDeferred<void>();
const firstRefreshContinue = createDeferred<void>();
let callCount = 0;
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
callCount++;
if (callCount === 1) {
// First call fails
firstRefreshStarted.resolve(undefined);
await firstRefreshContinue.promise;
return failingTokenResponse;
}
// Second call succeeds
return successTokenResponse;
}
return apiResponse;
});
// First call will fail, but subsequent calls should retry
const promise1 = provider.callApi('test 1').catch((err: Error) => {
expect(err.message).toMatch(/token|auth|refresh|401|fetch/i);
});
await firstRefreshStarted.promise;
// Second call should trigger a retry
const promise2 = provider.callApi('test 2');
firstRefreshContinue.resolve(undefined);
const response2 = await promise2;
expect(response2.error).toBeUndefined();
await promise1;
// Should have attempted token refresh twice (initial + retry)
const tokenRefreshCalls = vi
.mocked(fetchWithCache)
.mock.calls.filter((call) => call[0] === tokenUrl);
expect(tokenRefreshCalls).toHaveLength(2);
const apiCalls = vi.mocked(fetchWithCache).mock.calls.filter((call) => call[0] === mockUrl);
expect(apiCalls).toHaveLength(1);
const headers = apiCalls[0][1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe('Bearer retry-success-token');
});
it('should deduplicate retries when multiple callers observe a failed in-progress refresh', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const failingTokenResponse = {
data: JSON.stringify({ error: 'invalid_client' }),
status: 401,
statusText: 'Unauthorized',
cached: false,
};
const successTokenResponse = {
data: JSON.stringify({
access_token: 'retry-success-token',
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
const firstRefreshStarted = createDeferred<void>();
const firstRefreshContinue = createDeferred<void>();
const secondRefreshStarted = createDeferred<void>();
const secondRefreshContinue = createDeferred<void>();
let callCount = 0;
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
callCount++;
if (callCount === 1) {
firstRefreshStarted.resolve(undefined);
await firstRefreshContinue.promise;
return failingTokenResponse;
}
secondRefreshStarted.resolve(undefined);
await secondRefreshContinue.promise;
return successTokenResponse;
}
return apiResponse;
});
const promise1 = provider.callApi('same prompt').catch((err: Error) => {
expect(err.message).toMatch(/token|auth|refresh|401|fetch/i);
});
await firstRefreshStarted.promise;
const promise2 = provider.callApi('same prompt');
const promise3 = provider.callApi('same prompt');
firstRefreshContinue.resolve(undefined);
await secondRefreshStarted.promise;
secondRefreshContinue.resolve(undefined);
const [response2, response3] = await Promise.all([promise2, promise3]);
expect(response2.error).toBeUndefined();
expect(response3.error).toBeUndefined();
await promise1;
const tokenRefreshCalls = vi
.mocked(fetchWithCache)
.mock.calls.filter((call) => call[0] === tokenUrl);
expect(tokenRefreshCalls).toHaveLength(2);
const apiCalls = vi.mocked(fetchWithCache).mock.calls.filter((call) => call[0] === mockUrl);
expect(apiCalls).toHaveLength(2);
for (const apiCall of apiCalls) {
const headers = apiCall[1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe('Bearer retry-success-token');
}
});
it('should use cached token if refresh is already in progress', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const tokenResponse = {
data: JSON.stringify({
access_token: 'cached-token-789',
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
let tokenRefreshResolve: (value: any) => void;
const tokenRefreshPromise = new Promise((resolve) => {
tokenRefreshResolve = resolve;
});
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
await tokenRefreshPromise;
return tokenResponse;
}
return apiResponse;
});
// Start first call (will trigger token refresh)
const promise1 = provider.callApi('test 1');
// Wait until the token refresh has actually been issued before starting
// the second call, so it observes the in-flight refresh.
await vi.waitFor(() => {
expect(vi.mocked(fetchWithCache).mock.calls.some((call) => call[0] === tokenUrl)).toBe(true);
});
// Start second call (should wait for first refresh)
const promise2 = provider.callApi('test 2');
// Resolve token refresh
tokenRefreshResolve!(tokenResponse);
await Promise.all([promise1, promise2]);
// Should only have one token refresh call
const tokenRefreshCalls = vi
.mocked(fetchWithCache)
.mock.calls.filter((call) => call[0] === tokenUrl);
expect(tokenRefreshCalls).toHaveLength(1);
});
it('should include the refreshed token in API request headers', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const expectedToken = 'final-token-abc';
const tokenResponse = {
data: JSON.stringify({
access_token: expectedToken,
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
return tokenResponse;
}
return apiResponse;
});
await provider.callApi('test prompt');
// Find the API call (not the token refresh call)
const apiCall = vi.mocked(fetchWithCache).mock.calls.find((call) => call[0] === mockUrl);
expect(apiCall).toBeDefined();
const headers = apiCall![1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe(`Bearer ${expectedToken}`);
});
it('should expose the refreshed token as vars.token for header templating', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': '{{ token }}',
},
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const expectedToken = 'templated-header-token';
const tokenResponse = {
data: JSON.stringify({
access_token: expectedToken,
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
return urlString === tokenUrl ? tokenResponse : apiResponse;
});
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls.find((call) => call[0] === mockUrl);
expect(apiCall).toBeDefined();
const headers = apiCall![1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe(`Bearer ${expectedToken}`);
expect(headers?.['x-auth-token']).toBe(expectedToken);
});
it('should not inject a cached OAuth token when vars.token is absent', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
return {
data: JSON.stringify({ access_token: 'cached-oauth-token', expires_in: 3600 }),
status: 200,
statusText: 'OK',
cached: false,
};
}
return {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
});
await provider.callApi('test prompt');
await expect(provider.getHeaders({}, {})).resolves.not.toHaveProperty('authorization');
});
it('should expose the refreshed token as vars.token for body templating', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
key: '{{ prompt }}',
token: '{{ token }}',
},
auth: {
type: 'oauth',
grantType: 'client_credentials',
tokenUrl,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
});
const expectedToken = 'templated-body-token';
const tokenResponse = {
data: JSON.stringify({
access_token: expectedToken,
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
return urlString === tokenUrl ? tokenResponse : apiResponse;
});
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls.find((call) => call[0] === mockUrl);
expect(apiCall).toBeDefined();
expect(apiCall![1]?.body).toBe(
JSON.stringify({
key: 'test prompt',
token: expectedToken,
}),
);
});
it('should handle password grant type with deduplication', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'oauth',
grantType: 'password',
tokenUrl,
username: 'test-user',
password: 'test-password',
},
},
});
const tokenResponse = {
data: JSON.stringify({
access_token: 'password-grant-token',
expires_in: 3600,
}),
status: 200,
statusText: 'OK',
cached: false,
};
const apiResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
let refreshCallCount = 0;
vi.mocked(fetchWithCache).mockImplementation(async (url: RequestInfo) => {
const urlString =
typeof url === 'string' ? url : url instanceof Request ? url.url : String(url);
if (urlString === tokenUrl) {
refreshCallCount++;
// Yield so concurrent callers register on the in-flight refresh.
await Promise.resolve();
return tokenResponse;
}
return apiResponse;
});
// Make concurrent calls with password grant
await Promise.all([
provider.callApi('test 1'),
provider.callApi('test 2'),
provider.callApi('test 3'),
]);
// Should only refresh once
expect(refreshCallCount).toBe(1);
});
});
describe('HttpProvider - File Auth', () => {
const mockUrl = 'http://example.com/api';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
vi.mocked(fetchWithCache).mockResolvedValue({
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('should deduplicate retries when multiple callers observe a failed in-progress file auth refresh', async () => {
const firstRefreshStarted = createDeferred<void>();
const firstRefreshContinue = createDeferred<void>();
const secondRefreshStarted = createDeferred<void>();
const secondRefreshContinue = createDeferred<void>();
const authFn = vi
.fn()
.mockImplementationOnce(async () => {
firstRefreshStarted.resolve(undefined);
await firstRefreshContinue.promise;
throw new Error('file auth failed');
})
.mockImplementationOnce(async () => {
secondRefreshStarted.resolve(undefined);
await secondRefreshContinue.promise;
return {
token: 'retry-file-token',
expiration: Date.now() + 3_600_000,
};
});
vi.mocked(importModule).mockResolvedValue(authFn);
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer {{token}}',
},
body: { key: '{{ prompt }}' },
auth: {
type: 'file',
path: 'file://auth.js',
},
},
});
const promise1 = provider.callApi('same prompt').catch((err: Error) => {
expect(err.message).toMatch(/token|auth|refresh|401|fetch/i);
});
await firstRefreshStarted.promise;
const promise2 = provider.callApi('same prompt');
const promise3 = provider.callApi('same prompt');
firstRefreshContinue.resolve(undefined);
await secondRefreshStarted.promise;
secondRefreshContinue.resolve(undefined);
const [response2, response3] = await Promise.all([promise2, promise3]);
expect(response2.error).toBeUndefined();
expect(response3.error).toBeUndefined();
await promise1;
expect(authFn).toHaveBeenCalledTimes(2);
const apiCalls = vi.mocked(fetchWithCache).mock.calls.filter((call) => call[0] === mockUrl);
expect(apiCalls).toHaveLength(2);
for (const apiCall of apiCalls) {
const headers = apiCall[1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe('Bearer retry-file-token');
}
});
it('should parse auth.type file in the provider config schema', () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
expect(provider.config.auth).toEqual({
type: 'file',
path: './auth/get-token.js',
});
});
it('should inject a file auth token into templated headers, query params, and body', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'file-token-123',
expiration: Date.now() + 60_000,
});
vi.mocked(importModule).mockImplementation(
async (_modulePath: string, functionName?: string) => {
if (functionName) {
return authFn;
}
return { default: authFn };
},
);
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer {{token}}',
'X-Token-Expiry': '{{expiration}}',
},
queryParams: {
access_token: '{{token}}',
},
body: {
prompt: '{{prompt}}',
token: '{{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
});
expect(authFn).toHaveBeenCalledWith(
expect.objectContaining({
vars: expect.objectContaining({
prompt: 'test prompt',
}),
}),
);
expect(fetchWithCache).toHaveBeenCalledWith(
`${mockUrl}?access_token=file-token-123`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
authorization: 'Bearer file-token-123',
'x-token-expiry': expect.any(String),
}),
body: JSON.stringify({
prompt: 'test prompt',
token: 'file-token-123',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should support named TypeScript exports via file:// references', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'named-export-token',
});
vi.mocked(importModule).mockImplementation(
async (_modulePath: string, functionName?: string) => {
if (functionName === 'buildAuth') {
return authFn;
}
return { default: authFn };
},
);
const provider = new HttpProvider(mockUrl, {
config: {
request: dedent`
POST /chat HTTP/1.1
Host: example.com
Authorization: Bearer {{token}}
Content-Type: application/json
{"token":"{{token}}","prompt":"{{prompt}}"}
`,
auth: {
type: 'file',
path: 'file://./auth/get-token.ts:buildAuth',
},
},
});
await provider.callApi('raw prompt', {
prompt: { raw: 'raw prompt', label: 'raw prompt' },
vars: {},
});
const rawRequestCall = vi
.mocked(fetchWithCache)
.mock.calls.find((call) => String(call[0]) === 'http://example.com/chat');
expect(rawRequestCall).toBeDefined();
expect(rawRequestCall?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
authorization: 'Bearer named-export-token',
'content-type': 'application/json',
host: 'example.com',
}),
body: '{"token":"named-export-token","prompt":"raw prompt"}',
}),
);
});
it('should load Python auth files using get_auth by default', async () => {
vi.mocked(runPython).mockResolvedValue({
token: 'python-token',
});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.py',
},
},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
});
expect(runPython).toHaveBeenCalledWith(
path.resolve('/mock/base/path', './auth/get-token.py'),
'get_auth',
[
expect.objectContaining({
vars: expect.objectContaining({
prompt: 'test prompt',
}),
}),
],
);
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer python-token',
}),
method: 'GET',
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should reuse a non-expiring file auth token across requests', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'never-expire-token',
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: {},
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: {},
});
expect(authFn).toHaveBeenCalledTimes(1);
});
it('should not reuse file auth tokens across different vars', async () => {
const authFn = vi.fn((authContext: any) => ({
token: `token-for-${authContext.vars.userId}`,
}));
vi.mocked(importModule).mockResolvedValue(authFn);
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: 'alice', apiKey: 'secret-file-a' },
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: 'bob', apiKey: 'secret-file-b' },
});
expect(authFn).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetchWithCache).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer token-for-alice',
}),
}),
);
expect(vi.mocked(fetchWithCache).mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer token-for-bob',
}),
}),
);
const cacheKeys = Array.from((provider as any).authTokenCache.keys());
expect(cacheKeys).toHaveLength(2);
expect(cacheKeys).toEqual([
expect.stringMatching(/^file:[a-f0-9]{64}$/),
expect.stringMatching(/^file:[a-f0-9]{64}$/),
]);
expect(cacheKeys.join('\n')).not.toContain('alice');
expect(cacheKeys.join('\n')).not.toContain('bob');
expect(cacheKeys.join('\n')).not.toContain('secret-file');
});
it('should not reuse file auth tokens across different call contexts', async () => {
const authFn = vi.fn((authContext: any) => ({
token: `token-for-${authContext.testCaseId}-${authContext.repeatIndex}-${authContext.test.metadata.tenant}`,
}));
vi.mocked(importModule).mockResolvedValue(authFn);
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: 'same-user' },
testCaseId: 'case-a',
repeatIndex: 0,
test: {
metadata: { tenant: 'tenant-a' },
},
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: 'same-user' },
testCaseId: 'case-b',
repeatIndex: 1,
test: {
metadata: { tenant: 'tenant-b' },
},
});
expect(authFn).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetchWithCache).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer token-for-case-a-0-tenant-a',
}),
}),
);
expect(vi.mocked(fetchWithCache).mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer token-for-case-b-1-tenant-b',
}),
}),
);
const cacheKeys = Array.from((provider as any).authTokenCache.keys());
expect(cacheKeys).toHaveLength(2);
expect(cacheKeys).toEqual([
expect.stringMatching(/^file:[a-f0-9]{64}$/),
expect.stringMatching(/^file:[a-f0-9]{64}$/),
]);
expect(cacheKeys.join('\n')).not.toContain('case-a');
expect(cacheKeys.join('\n')).not.toContain('tenant-a');
});
it('should prune expired file auth tokens when caching a new token', async () => {
const authFn = vi.fn((authContext: any) => ({
token: `token-for-${authContext.vars.userId}`,
expiration: Date.now() + TOKEN_REFRESH_BUFFER_MS + 1_000,
}));
vi.mocked(importModule).mockResolvedValue(authFn);
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: 'alice' },
});
expect((provider as any).authTokenCache.size).toBe(1);
await vi.advanceTimersByTimeAsync(1_001);
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: 'bob' },
});
const cachedTokens = Array.from((provider as any).authTokenCache.values()).map(
(cachedToken: any) => cachedToken.token,
);
expect(cachedTokens).toEqual(['token-for-bob']);
});
it('should cap file auth token cache entries for long-lived providers', async () => {
const authFn = vi.fn((authContext: any) => ({
token: `token-for-${authContext.vars.userId}`,
}));
vi.mocked(importModule).mockResolvedValue(authFn);
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
for (let index = 0; index < 260; index++) {
await provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: `user-${index}` },
});
}
const cachedTokens = Array.from((provider as any).authTokenCache.values()).map(
(cachedToken: any) => cachedToken.token,
);
expect(cachedTokens).toHaveLength(256);
expect(cachedTokens).not.toContain('token-for-user-0');
expect(cachedTokens).toContain('token-for-user-259');
});
it('should return refreshed file auth tokens when concurrent keys exceed the cache limit', async () => {
let releaseRefreshes!: () => void;
const refreshesBlocked = new Promise<void>((resolve) => {
releaseRefreshes = resolve;
});
const authFn = vi.fn(async (authContext: any) => {
await refreshesBlocked;
return {
token: `token-for-${authContext.vars.userId}`,
};
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
const requests = Array.from({ length: 257 }, (_, index) =>
provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: { userId: `user-${index}` },
}),
);
await vi.waitFor(() => expect(authFn).toHaveBeenCalledTimes(257));
releaseRefreshes();
await expect(Promise.all(requests)).resolves.toHaveLength(257);
const authHeaders = new Set(
vi
.mocked(fetchWithCache)
.mock.calls.map((call) => (call[1]?.headers as Record<string, string>).authorization),
);
expect(authHeaders.size).toBe(257);
expect((provider as any).authTokenCache.size).toBe(256);
});
it('should refresh a file auth token when it is within the oauth refresh buffer', async () => {
const authFn = vi
.fn()
.mockResolvedValueOnce({
token: 'stale-token',
expiration: Date.now() + TOKEN_REFRESH_BUFFER_MS - 1,
})
.mockResolvedValueOnce({
token: 'fresh-token',
expiration: Date.now() + TOKEN_REFRESH_BUFFER_MS + 60_000,
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('first prompt', {
prompt: { raw: 'first prompt', label: 'first prompt' },
vars: {},
});
await provider.callApi('second prompt', {
prompt: { raw: 'second prompt', label: 'second prompt' },
vars: {},
});
expect(authFn).toHaveBeenCalledTimes(2);
const secondApiCall = vi.mocked(fetchWithCache).mock.calls[1];
expect(secondApiCall?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer fresh-token',
}),
}),
);
});
it('should deduplicate concurrent file auth refreshes', async () => {
const authFn = vi.fn(async () => {
// Yield so concurrent callers register on the in-flight refresh.
await Promise.resolve();
return {
token: 'shared-file-token',
expiration: Date.now() + TOKEN_REFRESH_BUFFER_MS + 60_000,
};
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
const requests = Promise.all([
provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: {},
}),
provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: {},
}),
provider.callApi('same prompt', {
prompt: { raw: 'same prompt', label: 'same prompt' },
vars: {},
}),
]);
await vi.advanceTimersByTimeAsync(50);
await requests;
expect(authFn).toHaveBeenCalledTimes(1);
for (const call of vi.mocked(fetchWithCache).mock.calls) {
expect(call[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer shared-file-token',
}),
}),
);
}
expect(debugSpy).toHaveBeenCalledWith('[HTTP Provider Auth]: Acquired file auth refresh lock');
expect(debugSpy).toHaveBeenCalledWith(
'[HTTP Provider Auth]: Invoking file auth function ./auth/get-token.js#default',
);
expect(
debugSpy.mock.calls.filter(
([message]) => message === '[HTTP Provider Auth]: Acquired file auth refresh lock',
),
).toHaveLength(1);
expect(
debugSpy.mock.calls.filter(
([message]) =>
message ===
'[HTTP Provider Auth]: Invoking file auth function ./auth/get-token.js#default',
),
).toHaveLength(1);
expect(
debugSpy.mock.calls.filter(
([message]) =>
message === '[HTTP Provider Auth]: Token refresh already in progress, waiting...',
).length,
).toBeGreaterThan(0);
});
it('should make file auth values available to transformRequest before the request is rendered', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'transform-token',
});
const transformRequest = vi.fn((_prompt: string, vars: Record<string, any>) => ({
transformedToken: vars.token,
transformedExpiration: vars.expiration,
}));
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {},
transformRequest,
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
});
expect(transformRequest).toHaveBeenCalledWith(
'test prompt',
expect.objectContaining({
token: 'transform-token',
expiration: undefined,
}),
expect.anything(),
);
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
body: JSON.stringify({
transformedToken: 'transform-token',
transformedExpiration: undefined,
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should make file auth values available when rendering the session endpoint config', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'session-token',
expiration: 1234567890,
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
prompt: '{{prompt}}',
session: '{{sessionId}}',
},
session: {
url: 'http://example.com/session',
method: 'POST',
headers: {
Authorization: 'Bearer {{token}}',
'X-Token-Expiration': '{{expiration}}',
},
body: {
token: '{{token}}',
},
responseParser: 'data.body.sessionId',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
vi.mocked(fetchWithCache)
.mockResolvedValueOnce({
data: JSON.stringify({ sessionId: 'session-123' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
})
.mockResolvedValueOnce({
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
});
const sessionCall = vi.mocked(fetchWithCache).mock.calls[0];
expect(sessionCall?.[0]).toBe('http://example.com/session');
expect(sessionCall?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
authorization: 'Bearer session-token',
'x-token-expiration': '1234567890',
}),
body: JSON.stringify({
token: 'session-token',
}),
}),
);
const mainCall = vi.mocked(fetchWithCache).mock.calls[1];
expect(mainCall?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
prompt: 'test prompt',
session: 'session-123',
}),
}),
);
});
it('should log how many seconds a refreshed file auth token stays fresh', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_000);
const authFn = vi.fn().mockResolvedValue({
token: 'expiring-token',
expiration: 43_000,
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
});
expect(infoSpy).toHaveBeenCalledWith(
'[HTTP Provider Auth]: Successfully refreshed file auth token (expires in 42 seconds)',
);
});
it('should log when a refreshed file auth token never expires', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'non-expiring-token',
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
});
expect(infoSpy).toHaveBeenCalledWith(
'[HTTP Provider Auth]: Successfully refreshed file auth token (never expires)',
);
});
it('should warn when file auth overwrites token vars', async () => {
const authFn = vi.fn().mockResolvedValue({
token: 'replacement-token',
expiration: 123456,
});
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {
token: 'existing-token',
expiration: 1,
},
});
expect(warnSpy).toHaveBeenCalledWith(
'[HTTP Provider Auth]: `token` is already defined in vars and will be overwritten',
);
expect(warnSpy).toHaveBeenCalledWith(
'[HTTP Provider Auth]: `expiration` is already defined in vars and will be overwritten',
);
});
it.each([
{ label: 'null', result: null },
{ label: 'string', result: 'token' },
{ label: 'missing token', result: { expiration: 123 } },
{ label: 'empty token', result: { token: '' } },
{ label: 'invalid expiration', result: { token: 'abc', expiration: 'soon' } },
])('should reject invalid file auth return values: $label', async ({ result }) => {
const authFn = vi.fn().mockResolvedValue(result);
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await expect(
provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
}),
).rejects.toThrow('Failed to refresh file auth token');
});
it('should surface thrown errors from the auth file', async () => {
const authFn = vi.fn().mockRejectedValue(new Error('boom'));
vi.mocked(importModule).mockImplementation(async () => ({ default: authFn }));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await expect(
provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
}),
).rejects.toThrow('Failed to refresh file auth token: Error: boom');
});
it('should surface missing JavaScript exports clearly', async () => {
vi.mocked(importModule).mockImplementation(async () => ({
default: {
notAFunction: true,
},
}));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/get-token.js',
},
},
});
await expect(
provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
}),
).rejects.toThrow('JavaScript file must export a function');
});
it('should surface missing files clearly', async () => {
vi.mocked(importModule).mockRejectedValue(new Error('ENOENT: no such file or directory'));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: './auth/missing.js',
},
},
});
await expect(
provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
}),
).rejects.toThrow('ENOENT: no such file or directory');
});
it('should surface missing Python function names clearly', async () => {
vi.mocked(runPython).mockRejectedValue(new Error("Function 'missing_auth' not found"));
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: {
Authorization: 'Bearer {{token}}',
},
auth: {
type: 'file',
path: 'file://./auth/get-token.py:missing_auth',
},
},
});
await expect(
provider.callApi('test prompt', {
prompt: { raw: 'test prompt', label: 'test prompt' },
vars: {},
}),
).rejects.toThrow("Function 'missing_auth' not found");
});
});
describe('HttpProvider - Bearer Authentication', () => {
const mockUrl = 'http://example.com/api';
beforeEach(() => {
vi.clearAllMocks();
});
it('should add Bearer token to Authorization header', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'bearer',
token: 'my-secret-token-123',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'content-type': 'application/json',
authorization: 'Bearer my-secret-token-123',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should add Bearer token in raw request mode', async () => {
const rawRequest = dedent`
POST /api HTTP/1.1
Host: example.com
Content-Type: application/json
{"key": "{{ prompt }}"}
`;
const provider = new HttpProvider('http://example.com', {
config: {
request: rawRequest,
auth: {
type: 'bearer',
token: 'raw-request-token-456',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi
.mocked(fetchWithCache)
.mock.calls.find((call) => String(call[0]).includes('/api'));
expect(apiCall).toBeDefined();
const headers = apiCall![1]?.headers as Record<string, string> | undefined;
expect(headers?.authorization).toBe('Bearer raw-request-token-456');
});
});
describe('HttpProvider - API Key Authentication', () => {
const mockUrl = 'http://example.com/api';
beforeEach(() => {
vi.clearAllMocks();
});
it('should add API key to header when placement is header', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'api_key',
value: 'my-api-key-123',
placement: 'header',
keyName: 'X-API-Key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'content-type': 'application/json',
'x-api-key': 'my-api-key-123',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should add API key to query params when placement is query', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
auth: {
type: 'api_key',
value: 'query-api-key-456',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
// Check that the URL includes the API key as a query parameter
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
expect(url).toContain('api_key=query-api-key-456');
expect(url).toContain('api_key=');
});
it('should add API key to query params and merge with existing query params', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
queryParams: {
foo: 'bar',
},
auth: {
type: 'api_key',
value: 'merged-api-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
// Check that the URL includes both the existing query param and the API key
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
expect(url).toContain('foo=bar');
expect(url).toContain('api_key=merged-api-key');
});
it('should add API key to header in raw request mode', async () => {
const rawRequest = dedent`
POST /api HTTP/1.1
Host: example.com
Content-Type: application/json
{"key": "{{ prompt }}"}
`;
const provider = new HttpProvider('http://example.com', {
config: {
request: rawRequest,
auth: {
type: 'api_key',
value: 'raw-header-key',
placement: 'header',
keyName: 'X-API-Key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi
.mocked(fetchWithCache)
.mock.calls.find((call) => String(call[0]).includes('/api'));
expect(apiCall).toBeDefined();
const headers = apiCall![1]?.headers as Record<string, string> | undefined;
expect(headers?.['x-api-key']).toBe('raw-header-key');
});
it('should add API key to query params in raw request mode', async () => {
const rawRequest = dedent`
GET /api/data HTTP/1.1
Host: example.com
Content-Type: application/json
`;
const provider = new HttpProvider('http://example.com', {
config: {
request: rawRequest,
auth: {
type: 'api_key',
value: 'raw-query-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi
.mocked(fetchWithCache)
.mock.calls.find((call) => String(call[0]).includes('/api'));
expect(apiCall).toBeDefined();
const url = apiCall![0] as string;
expect(url).toContain('api_key=raw-query-key');
});
it('should use custom key name for API key header', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'api_key',
value: 'custom-key-value',
placement: 'header',
keyName: 'Authorization',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
mockUrl,
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'custom-key-value',
}),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should add API key to query params when URL already has query parameters', async () => {
const urlWithQuery = 'http://example.com/api?existing=value&other=param';
const provider = new HttpProvider(urlWithQuery, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
auth: {
type: 'api_key',
value: 'new-api-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
// Should contain all query params
expect(url).toContain('existing=value');
expect(url).toContain('other=param');
expect(url).toContain('api_key=new-api-key');
// Should be a valid URL with all params
const urlObj = new URL(url);
expect(urlObj.searchParams.get('existing')).toBe('value');
expect(urlObj.searchParams.get('other')).toBe('param');
expect(urlObj.searchParams.get('api_key')).toBe('new-api-key');
});
it('should add API key to query params with config queryParams and URL query params', async () => {
const urlWithQuery = 'http://example.com/api?urlParam=urlValue';
const provider = new HttpProvider(urlWithQuery, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
queryParams: {
configParam: 'configValue',
},
auth: {
type: 'api_key',
value: 'triple-merge-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
const urlObj = new URL(url);
// Should contain all three sources of query params
expect(urlObj.searchParams.get('urlParam')).toBe('urlValue');
expect(urlObj.searchParams.get('configParam')).toBe('configValue');
expect(urlObj.searchParams.get('api_key')).toBe('triple-merge-key');
});
it('should properly URL encode API key value in query params', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
auth: {
type: 'api_key',
value: 'key with spaces & special=chars',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
const urlObj = new URL(url);
// Should properly decode the value (URLSearchParams handles encoding/decoding)
expect(urlObj.searchParams.get('api_key')).toBe('key with spaces & special=chars');
// Should be URL encoded in the actual URL string
expect(url).toContain('api_key=');
// Verify special characters are encoded (not present as literals)
expect(url).not.toContain('api_key=key with spaces'); // Should not have unencoded spaces
expect(url).not.toContain('& special'); // Should not have unencoded &
expect(url).not.toContain('special=chars'); // Should not have unencoded =
});
it('should add API key to query params with custom key name', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
auth: {
type: 'api_key',
value: 'custom-name-key',
placement: 'query',
keyName: 'X-API-Key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
const urlObj = new URL(url);
// Should use the custom key name
expect(urlObj.searchParams.get('X-API-Key')).toBe('custom-name-key');
expect(url).toContain('X-API-Key=custom-name-key');
});
it('should add API key to query params in POST requests', async () => {
const provider = new HttpProvider(mockUrl, {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
auth: {
type: 'api_key',
value: 'post-query-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
const urlObj = new URL(url);
expect(urlObj.searchParams.get('api_key')).toBe('post-query-key');
// Should still have the body
expect(apiCall[1]?.body).toBeDefined();
});
it('should add API key to query params in raw request mode with existing query params', async () => {
const rawRequest = dedent`
GET /api/data?existing=value&other=param HTTP/1.1
Host: example.com
Content-Type: application/json
`;
const provider = new HttpProvider('http://example.com', {
config: {
request: rawRequest,
auth: {
type: 'api_key',
value: 'raw-query-merge-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi
.mocked(fetchWithCache)
.mock.calls.find((call) => String(call[0]).includes('/api'));
expect(apiCall).toBeDefined();
const url = apiCall![0] as string;
const urlObj = new URL(url);
// Should contain all query params including the API key
expect(urlObj.searchParams.get('existing')).toBe('value');
expect(urlObj.searchParams.get('other')).toBe('param');
expect(urlObj.searchParams.get('api_key')).toBe('raw-query-merge-key');
});
it('should handle API key query param with URL that has hash fragment', async () => {
const urlWithHash = 'http://example.com/api#fragment';
const provider = new HttpProvider(urlWithHash, {
config: {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
auth: {
type: 'api_key',
value: 'hash-url-key',
placement: 'query',
keyName: 'api_key',
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt');
const apiCall = vi.mocked(fetchWithCache).mock.calls[0];
const url = apiCall[0] as string;
const urlObj = new URL(url);
expect(urlObj.searchParams.get('api_key')).toBe('hash-url-key');
// Hash should be preserved
expect(urlObj.hash).toBe('#fragment');
});
});