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
3835 lines
120 KiB
TypeScript
3835 lines
120 KiB
TypeScript
// Core HttpProvider tests: API calls, raw requests, body processing, headers, response transforms, sanitization.
|
|
import './setup';
|
|
|
|
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 {
|
|
escapeJsonVariables,
|
|
extractBodyFromRawRequest,
|
|
HttpProvider,
|
|
processJsonBody,
|
|
processTextBody,
|
|
urlEncodeRawRequestPath,
|
|
} from '../../../src/providers/http';
|
|
import { maybeLoadConfigFromExternalFile, maybeLoadFromExternalFile } from '../../../src/util/file';
|
|
import { sanitizeObject, sanitizeUrl } from '../../../src/util/sanitizer';
|
|
import { mockProcessEnv } from '../../util/utils';
|
|
|
|
describe('HttpProvider', () => {
|
|
const mockUrl = 'http://example.com/api';
|
|
let provider: HttpProvider;
|
|
|
|
it('should call the API and return the response', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: '{{ prompt }}' },
|
|
transformResponse: (data: any) => data.result,
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'response text' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toBe('response text');
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ key: 'test prompt' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should handle API call errors', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: 'value' },
|
|
transformResponse: (data: any) => data.result,
|
|
},
|
|
});
|
|
const mockError = new Error('Network error');
|
|
vi.mocked(fetchWithCache).mockRejectedValueOnce(mockError);
|
|
|
|
await expect(provider.callApi('test prompt')).rejects.toThrow('Network error');
|
|
});
|
|
|
|
it('should use custom method/headers/queryParams', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'PATCH',
|
|
headers: { Authorization: 'Bearer token' },
|
|
body: { key: '{{ prompt }}' },
|
|
queryParams: { foo: 'bar' },
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'custom response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
`${mockUrl}?foo=bar`,
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json', authorization: 'Bearer token' },
|
|
body: JSON.stringify({ key: 'test prompt' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should substitute variables in URL path parameters', async () => {
|
|
const urlWithPathParam = 'http://example.com/users/{{userId}}/profile';
|
|
provider = new HttpProvider(urlWithPathParam, {
|
|
config: {
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ user: 'data' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt', {
|
|
vars: { userId: '12345' },
|
|
prompt: { raw: 'foo', label: 'bar' },
|
|
});
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'http://example.com/users/12345/profile',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
const testCases = [
|
|
{ parser: (data: any) => data.custom, expected: 'parsed' },
|
|
{ parser: 'json.result', expected: 'parsed' },
|
|
{ parser: 'text', expected: JSON.stringify({ result: 'parsed', custom: 'parsed' }) },
|
|
];
|
|
|
|
testCases.forEach(({ parser, expected }) => {
|
|
it(`should handle response transform type: ${parser}`, async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
body: { key: '{{ prompt }}' },
|
|
transformResponse: parser,
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'parsed', custom: 'parsed' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
it('should correctly render Nunjucks templates in config', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'X-Custom-Header': '{{ prompt | upper }}' },
|
|
body: { key: '{{ prompt }}' },
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'custom response',
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'x-custom-header': 'TEST PROMPT' },
|
|
body: JSON.stringify({ key: 'test prompt' }),
|
|
},
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should expose evaluationId in template vars', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Evaluation-Id': '{{evaluationId}}',
|
|
},
|
|
body: {
|
|
query: '{{prompt}}',
|
|
evaluation_id: '{{evaluationId}}',
|
|
},
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'ok',
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt', {
|
|
vars: {},
|
|
prompt: { raw: 'test prompt', label: 'test' },
|
|
evaluationId: 'eval-123',
|
|
});
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-evaluation-id': 'eval-123',
|
|
},
|
|
body: JSON.stringify({
|
|
query: 'test prompt',
|
|
evaluation_id: 'eval-123',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should throw an error when creating HttpProvider with invalid config', () => {
|
|
const invalidConfig = 'this isnt json';
|
|
expect(() => {
|
|
new HttpProvider(mockUrl, {
|
|
config: invalidConfig as any,
|
|
});
|
|
}).toThrow(/expected object, received string/i);
|
|
});
|
|
|
|
it('should return provider id and string representation', () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: { body: 'yo mama' },
|
|
});
|
|
expect(provider.id()).toBe(mockUrl);
|
|
expect(provider.toString()).toBe(`[HTTP Provider ${mockUrl}]`);
|
|
});
|
|
|
|
it('should handle GET requests with query parameters', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
queryParams: {
|
|
q: '{{ prompt }}',
|
|
foo: 'bar',
|
|
},
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'response data',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
`${mockUrl}?q=test+prompt&foo=bar`,
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
describe('raw request', () => {
|
|
it('should handle a basic GET raw request', async () => {
|
|
const rawRequest = dedent`
|
|
GET /api/data HTTP/1.1
|
|
Host: example.com
|
|
User-Agent: TestAgent/1.0
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'http://example.com/api/data',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
'user-agent': 'TestAgent/1.0',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should handle a POST raw request with body and variable substitution', async () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"data": "{{prompt}}"}
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'received' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test data');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/submit',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
host: 'example.com',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: '{"data": "test data"}',
|
|
},
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'received' });
|
|
});
|
|
|
|
it('should handle a raw request with path parameter variable substitution', async () => {
|
|
const rawRequest = dedent`
|
|
GET /api/users/{{userId}}/profile HTTP/1.1
|
|
Host: example.com
|
|
Accept: application/json
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ user: { id: '12345', name: 'Test User' } }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt', {
|
|
vars: { userId: '12345' },
|
|
prompt: { raw: 'foo', label: 'bar' },
|
|
});
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/users/12345/profile',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
accept: 'application/json',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ user: { id: '12345', name: 'Test User' } });
|
|
});
|
|
|
|
it('should load raw request from file if file:// prefix is used', async () => {
|
|
const filePath = 'file://path/to/request.txt';
|
|
const fileContent = dedent`
|
|
GET /api/data HTTP/1.1
|
|
Host: example.com
|
|
`;
|
|
vi.mocked(maybeLoadFromExternalFile).mockImplementationOnce(function () {
|
|
return fileContent;
|
|
});
|
|
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: filePath,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(maybeLoadFromExternalFile).toHaveBeenCalledWith(filePath);
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/data',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should throw an error for invalid raw requests', async () => {
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: 'yo mama',
|
|
},
|
|
});
|
|
await expect(provider.callApi('test prompt')).rejects.toThrow(/not valid/);
|
|
});
|
|
|
|
it('should remove content-length header from raw request', async () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
Content-Length: 1234
|
|
|
|
{"data": "test"}
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'received' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/submit',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: {
|
|
host: 'example.com',
|
|
'content-type': 'application/json',
|
|
// content-length should not be present
|
|
},
|
|
body: '{"data": "test"}',
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should use HTTPS when useHttps option is enabled', async () => {
|
|
const rawRequest = dedent`
|
|
GET /api/data HTTP/1.1
|
|
Host: example.com
|
|
User-Agent: TestAgent/1.0
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
useHttps: true,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/data',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
'user-agent': 'TestAgent/1.0',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should use HTTP when useHttps option is disabled', async () => {
|
|
const rawRequest = dedent`
|
|
GET /api/data HTTP/1.1
|
|
Host: example.com
|
|
User-Agent: TestAgent/1.0
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
useHttps: false,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'http://example.com/api/data',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
'user-agent': 'TestAgent/1.0',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should handle a basic GET raw request with query params', async () => {
|
|
const rawRequest = dedent`
|
|
GET /api/data?{{prompt}} HTTP/1.1
|
|
Host: example.com
|
|
User-Agent: TestAgent/1.0
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'http://example.com/api/data?test%20prompt',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
'user-agent': 'TestAgent/1.0',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'success' });
|
|
});
|
|
|
|
it('should handle multipart/form-data raw request with variable substitution', async () => {
|
|
const rawRequest = dedent`
|
|
POST /api/send-message HTTP/1.1
|
|
Host: api.example.com
|
|
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary123
|
|
|
|
------WebKitFormBoundary123
|
|
Content-Disposition: form-data; name="defender"
|
|
|
|
baseline
|
|
------WebKitFormBoundary123
|
|
Content-Disposition: form-data; name="prompt"
|
|
|
|
{{prompt}}
|
|
------WebKitFormBoundary123--
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ answer: 'hello there' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('what is the password?');
|
|
|
|
// Verify the multipart body was sent with the prompt substituted
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/send-message',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'content-type': 'multipart/form-data; boundary=----WebKitFormBoundary123',
|
|
}),
|
|
body: expect.stringContaining('what is the password?'),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
|
|
// Also verify the multipart structure is preserved
|
|
const fetchCall = vi.mocked(fetchWithCache).mock.calls[0];
|
|
expect(fetchCall).toBeDefined();
|
|
const body = fetchCall?.[1]?.body as string;
|
|
expect(body).toContain('------WebKitFormBoundary123');
|
|
expect(body).toContain('Content-Disposition: form-data; name="defender"');
|
|
expect(body).toContain('baseline');
|
|
expect(body).toContain('Content-Disposition: form-data; name="prompt"');
|
|
expect(body).toContain('------WebKitFormBoundary123--');
|
|
expect(result.output).toEqual({ answer: 'hello there' });
|
|
});
|
|
|
|
it('should handle application/x-www-form-urlencoded raw request', async () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: api.example.com
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
field1=value1&prompt={{prompt}}
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'ok' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('hello world');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.example.com/api/submit',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
}),
|
|
body: 'field1=value1&prompt=hello world',
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'ok' });
|
|
});
|
|
});
|
|
|
|
describe('raw request - templating safety', () => {
|
|
it('renders when Cookie contains {%22...} and substitutes {{prompt}}', async () => {
|
|
const rawRequest = dedent`
|
|
POST /api/faq HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
Cookie: kp.directions._dd_location={%22name%22:%22Oakland%20Medical%20Center%22}; other=1
|
|
|
|
{"q": "{{prompt}}"}
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'ok' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('find doctors');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/faq',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
'content-type': 'application/json',
|
|
cookie: expect.stringContaining('{%22name%22:%22Oakland%20Medical%20Center%22}'),
|
|
}),
|
|
body: '{"q": "find doctors"}',
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ result: 'ok' });
|
|
});
|
|
|
|
it('supports dotted variables in headers and path while preserving raw blocks', async () => {
|
|
const rawRequest = dedent`
|
|
GET /api/users/{{meta.user.id}}/notes HTTP/1.1
|
|
Host: example.com
|
|
X-User: {{meta.user.id}}
|
|
Accept: application/json
|
|
`;
|
|
const provider = new HttpProvider('https', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ ok: true }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('ignored', {
|
|
vars: { meta: { user: { id: 'abc123' } } },
|
|
prompt: { raw: 'x', label: 'y' },
|
|
});
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://example.com/api/users/abc123/notes',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
host: 'example.com',
|
|
accept: 'application/json',
|
|
'x-user': 'abc123',
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ ok: true });
|
|
});
|
|
|
|
it('normalizes mixed LF/CRLF line endings and parses correctly', async () => {
|
|
const mixed = 'GET /api/data HTTP/1.1\nHost: example.com\r\nUser-Agent: Test\n\n';
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: mixed,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ ok: true }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('p');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'http://example.com/api/data',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({ host: 'example.com', 'user-agent': 'Test' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(result.output).toEqual({ ok: true });
|
|
});
|
|
});
|
|
|
|
describe('processJsonBody', () => {
|
|
it('should process simple key-value pairs', () => {
|
|
const body = { key: 'value', prompt: '{{ prompt }}' };
|
|
const vars = { prompt: 'test prompt' };
|
|
const result = processJsonBody(body, vars);
|
|
expect(result).toEqual({ key: 'value', prompt: 'test prompt' });
|
|
});
|
|
|
|
it('should process nested objects', () => {
|
|
const body = {
|
|
outer: {
|
|
inner: '{{ prompt }}',
|
|
static: 'value',
|
|
},
|
|
};
|
|
const vars = { prompt: 'test prompt' };
|
|
const result = processJsonBody(body, vars);
|
|
expect(result).toEqual({
|
|
outer: {
|
|
inner: 'test prompt',
|
|
static: 'value',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should process arrays', () => {
|
|
const body = {
|
|
list: ['{{ prompt }}', 'static', '{{ prompt }}'],
|
|
};
|
|
const vars = { prompt: 'test prompt' };
|
|
const result = processJsonBody(body, vars);
|
|
expect(result).toEqual({
|
|
list: ['test prompt', 'static', 'test prompt'],
|
|
});
|
|
});
|
|
|
|
it('should process deeply nested objects and arrays', () => {
|
|
const body = {
|
|
key: '{{var1}}',
|
|
nested: {
|
|
key2: '{{var2}}',
|
|
items: ['{{var3}}', { nestedKey: '{{var4}}' }],
|
|
},
|
|
};
|
|
const vars = { var1: 'value1', var2: 'value2', var3: 'value3', var4: 'value4' };
|
|
const result = processJsonBody(body, vars);
|
|
expect(result).toEqual({
|
|
key: 'value1',
|
|
nested: {
|
|
key2: 'value2',
|
|
items: ['value3', { nestedKey: 'value4' }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should parse JSON strings if possible', () => {
|
|
const body = {
|
|
key: '{{var1}}',
|
|
jsonString: '{"parsed":{{var2}}}',
|
|
};
|
|
const vars = { var1: 'value1', var2: 123 };
|
|
const result = processJsonBody(body, vars);
|
|
expect(result).toEqual({
|
|
key: 'value1',
|
|
jsonString: { parsed: 123 },
|
|
});
|
|
});
|
|
|
|
describe('Raw JSON string handling (YAML literal case)', () => {
|
|
it('should return raw JSON strings as-is with control characters', () => {
|
|
// Simulate a YAML literal string that contains control characters
|
|
const body = '{\n "input": "Text with control char: \u0001",\n "role": "user"\n}';
|
|
const vars = { prompt: 'test' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return string as-is since it's already in intended format
|
|
expect(result).toBe('{\n "input": "Text with control char: \u0001",\n "role": "user"\n}');
|
|
});
|
|
|
|
it('should return raw JSON strings as-is with bad syntax', () => {
|
|
// Simulate malformed JSON that would fail parsing
|
|
const body = '{\n "input": "{{prompt}}",\n "role": "user",\n}'; // trailing comma
|
|
const vars = { prompt: 'test prompt' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return string as-is since it's already in intended format
|
|
expect(result).toBe('{\n "input": "test prompt",\n "role": "user",\n}');
|
|
});
|
|
|
|
it('should parse valid JSON strings normally', () => {
|
|
// Valid JSON string should be parsed into object
|
|
const body = '{"input": "{{prompt}}", "role": "user"}';
|
|
const vars = { prompt: 'test prompt' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return parsed object since JSON.parse succeeds
|
|
expect(result).toEqual({
|
|
input: 'test prompt',
|
|
role: 'user',
|
|
});
|
|
});
|
|
|
|
it('should handle JSON primitive strings correctly', () => {
|
|
// JSON string literals should be parsed
|
|
const body = '"{{prompt}}"';
|
|
const vars = { prompt: 'hello world' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return the string value (not wrapped)
|
|
expect(result).toBe('hello world');
|
|
});
|
|
|
|
it('should handle JSON number strings correctly', () => {
|
|
const body = '{{number}}';
|
|
const vars = { number: 42 };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return the number value
|
|
expect(result).toBe(42);
|
|
});
|
|
|
|
it('should handle JSON boolean strings correctly', () => {
|
|
const body = '{{bool}}';
|
|
const vars = { bool: true };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return the boolean value
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should preserve numeric strings as strings in object bodies', () => {
|
|
// This simulates the case where YAML has session: '1234'
|
|
// The string should remain a string, not be converted to a number
|
|
const body = {
|
|
messages: '{{prompt}}',
|
|
session: '1234',
|
|
};
|
|
const vars = { prompt: 'test prompt' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// session should remain a string, not be converted to number
|
|
expect(result).toEqual({
|
|
messages: 'test prompt',
|
|
session: '1234', // Should be string, not number
|
|
});
|
|
expect(typeof (result as Record<string, any>).session).toBe('string');
|
|
});
|
|
|
|
it('should preserve boolean-like strings as strings', () => {
|
|
const body = {
|
|
flag: 'true',
|
|
enabled: 'false',
|
|
};
|
|
const vars = {};
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should remain strings, not be converted to booleans
|
|
expect(result).toEqual({
|
|
flag: 'true',
|
|
enabled: 'false',
|
|
});
|
|
expect(typeof (result as Record<string, any>).flag).toBe('string');
|
|
expect(typeof (result as Record<string, any>).enabled).toBe('string');
|
|
});
|
|
|
|
it('should still parse JSON objects and arrays', () => {
|
|
// JSON objects and arrays should still be parsed
|
|
const body = {
|
|
config: '{"key": "value"}',
|
|
items: '["a", "b"]',
|
|
session: '1234', // Should stay as string
|
|
};
|
|
const vars = {};
|
|
const result = processJsonBody(body, vars);
|
|
|
|
expect(result).toEqual({
|
|
config: { key: 'value' }, // Parsed to object
|
|
items: ['a', 'b'], // Parsed to array
|
|
session: '1234', // Remains as string
|
|
});
|
|
expect(typeof (result as Record<string, any>).config).toBe('object');
|
|
expect(Array.isArray((result as Record<string, any>).items)).toBe(true);
|
|
expect(typeof (result as Record<string, any>).session).toBe('string');
|
|
});
|
|
|
|
it('should handle complex nested JSON with control characters', () => {
|
|
// Complex nested structure with control characters
|
|
const body = `{
|
|
"user": {
|
|
"query": "{{prompt}}",
|
|
"metadata": {
|
|
"session": "abc\u0001def",
|
|
"tags": ["test", "debug\u0002"]
|
|
}
|
|
},
|
|
"options": {
|
|
"model": "gpt-4",
|
|
"temperature": 0.7
|
|
}
|
|
}`;
|
|
const vars = { prompt: 'What is AI?' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return string as-is since it's already in intended format
|
|
expect(result).toBe(`{
|
|
"user": {
|
|
"query": "What is AI?",
|
|
"metadata": {
|
|
"session": "abc\u0001def",
|
|
"tags": ["test", "debug\u0002"]
|
|
}
|
|
},
|
|
"options": {
|
|
"model": "gpt-4",
|
|
"temperature": 0.7
|
|
}
|
|
}`);
|
|
});
|
|
|
|
it('should handle JSON with random whitespace and indentation', () => {
|
|
// JSON with inconsistent formatting
|
|
const body = `{
|
|
"input": "{{prompt}}",
|
|
"role": "engineering",
|
|
"config": {
|
|
"debug":true ,
|
|
"timeout": 5000,
|
|
}
|
|
}`;
|
|
const vars = { prompt: 'Test with whitespace' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return string as-is since it's already in intended format
|
|
expect(result).toBe(`{
|
|
"input": "Test with whitespace",
|
|
"role": "engineering",
|
|
"config": {
|
|
"debug":true ,
|
|
"timeout": 5000,
|
|
}
|
|
}`);
|
|
});
|
|
|
|
it('should handle deeply nested arrays with template variables', () => {
|
|
// Deep nesting with trailing comma
|
|
const body = `{
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": "{{systemPrompt}}"
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "{{prompt}}",
|
|
"attachments": [
|
|
{"type": "image", "url": "{{imageUrl}}"},
|
|
{"type": "document", "data": "{{docData}}"}
|
|
]
|
|
}
|
|
],
|
|
"stream": {{streaming}},
|
|
}`;
|
|
const vars = {
|
|
systemPrompt: 'You are a helpful assistant',
|
|
prompt: 'Analyze this data',
|
|
imageUrl: 'https://example.com/image.jpg',
|
|
docData: 'base64encodeddata',
|
|
streaming: false,
|
|
};
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return string as-is since it's already in intended format
|
|
expect(result).toBe(`{
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": "You are a helpful assistant"
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "Analyze this data",
|
|
"attachments": [
|
|
{"type": "image", "url": "https://example.com/image.jpg"},
|
|
{"type": "document", "data": "base64encodeddata"}
|
|
]
|
|
}
|
|
],
|
|
"stream": false,
|
|
}`);
|
|
});
|
|
|
|
it('should handle multiline strings with special characters', () => {
|
|
// Multiline JSON with special characters and newlines
|
|
const body = `{
|
|
"query": "{{prompt}}",
|
|
"system_message": "You are a helpful AI.\\n\\nRules:\\n- Be concise\\n- Use examples\\n- Handle edge cases",
|
|
"special_chars": "Quotes: \\"test\\" and symbols: @#$%^&*()",
|
|
"unicode": "Emoji: 🤖 and unicode: \\u00A9"
|
|
}`;
|
|
const vars = { prompt: 'How does this work?' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// This should actually parse successfully since it's valid JSON
|
|
expect(result).toEqual({
|
|
query: 'How does this work?',
|
|
system_message:
|
|
'You are a helpful AI.\n\nRules:\n- Be concise\n- Use examples\n- Handle edge cases',
|
|
special_chars: 'Quotes: "test" and symbols: @#$%^&*()',
|
|
unicode: 'Emoji: 🤖 and unicode: ©',
|
|
});
|
|
});
|
|
|
|
it('should handle mixed valid and invalid JSON syntax', () => {
|
|
// JSON that looks valid but has subtle syntax errors
|
|
const body = `{
|
|
"valid_field": "{{prompt}}",
|
|
"numbers": [1, 2, 3,],
|
|
"object": {
|
|
"nested": true,
|
|
"value": "test"
|
|
},
|
|
"trailing_comma": "problem",
|
|
}`;
|
|
const vars = { prompt: 'Test input' };
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should return string as-is since it's already in intended format
|
|
expect(result).toBe(`{
|
|
"valid_field": "Test input",
|
|
"numbers": [1, 2, 3,],
|
|
"object": {
|
|
"nested": true,
|
|
"value": "test"
|
|
},
|
|
"trailing_comma": "problem",
|
|
}`);
|
|
});
|
|
|
|
it('should auto-escape newlines in JSON templates (YAML literal case)', () => {
|
|
// This is the real-world case: YAML literal string with unescaped newlines from red team
|
|
const body = '{\n "message": "{{prompt}}"\n}';
|
|
const vars = {
|
|
prompt: 'Multi-line prompt\nwith actual newlines\nand more text',
|
|
};
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should automatically escape the newlines and return parsed JSON object
|
|
expect(result).toEqual({
|
|
message: 'Multi-line prompt\nwith actual newlines\nand more text',
|
|
});
|
|
});
|
|
|
|
it('should auto-escape quotes and special chars in JSON templates', () => {
|
|
// Test various special characters that break JSON
|
|
const body = '{\n "message": "{{prompt}}",\n "role": "user"\n}';
|
|
const vars = {
|
|
prompt: 'Text with "quotes" and \ttabs and \nmore stuff',
|
|
};
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should automatically escape and return parsed JSON object
|
|
expect(result).toEqual({
|
|
message: 'Text with "quotes" and \ttabs and \nmore stuff',
|
|
role: 'user',
|
|
});
|
|
});
|
|
|
|
it('should fall back gracefully when JSON template cannot be fixed', () => {
|
|
// Test case where even escaping cannot fix the JSON (structural issues)
|
|
const body = '{\n "message": "{{prompt}}"\n missing_comma: true\n}';
|
|
const vars = {
|
|
prompt: 'Some text with\nnewlines',
|
|
};
|
|
const result = processJsonBody(body, vars);
|
|
|
|
// Should fall back to returning the original rendered string (with literal newlines)
|
|
expect(result).toBe('{\n "message": "Some text with\nnewlines"\n missing_comma: true\n}');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('escapeJsonVariables', () => {
|
|
it('should escape newlines in string values', () => {
|
|
const vars = { prompt: 'Line 1\nLine 2' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.prompt).toBe('Line 1\\nLine 2');
|
|
});
|
|
|
|
it('should escape carriage returns in string values', () => {
|
|
const vars = { text: 'Before\rAfter' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('Before\\rAfter');
|
|
});
|
|
|
|
it('should escape tabs in string values', () => {
|
|
const vars = { text: 'Before\tAfter' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('Before\\tAfter');
|
|
});
|
|
|
|
it('should escape quotes in string values', () => {
|
|
const vars = { text: 'He said "hello"' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('He said \\"hello\\"');
|
|
});
|
|
|
|
it('should escape backslashes in string values', () => {
|
|
const vars = { path: 'C:\\Users\\test' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.path).toBe('C:\\\\Users\\\\test');
|
|
});
|
|
|
|
it('should handle mixed special characters', () => {
|
|
const vars = {
|
|
prompt: 'Line 1\nLine 2\tTabbed\r\nWindows line',
|
|
};
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.prompt).toBe('Line 1\\nLine 2\\tTabbed\\r\\nWindows line');
|
|
});
|
|
|
|
it('should not escape non-string values', () => {
|
|
const vars = {
|
|
count: 42,
|
|
active: true,
|
|
items: null,
|
|
ratio: 3.14,
|
|
};
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.count).toBe(42);
|
|
expect(result.active).toBe(true);
|
|
expect(result.items).toBe(null);
|
|
expect(result.ratio).toBe(3.14);
|
|
});
|
|
|
|
it('should handle objects with mixed types', () => {
|
|
const vars = {
|
|
text: 'Hello\nWorld',
|
|
number: 123,
|
|
bool: false,
|
|
nested: 'Quote: "test"',
|
|
};
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('Hello\\nWorld');
|
|
expect(result.number).toBe(123);
|
|
expect(result.bool).toBe(false);
|
|
expect(result.nested).toBe('Quote: \\"test\\"');
|
|
});
|
|
|
|
it('should escape unicode control characters', () => {
|
|
const vars = { text: 'Before\u0001After' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('Before\\u0001After');
|
|
});
|
|
|
|
it('should handle empty strings', () => {
|
|
const vars = { text: '' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('');
|
|
});
|
|
|
|
it('should handle strings with only special characters', () => {
|
|
const vars = { text: '\n\r\t' };
|
|
const result = escapeJsonVariables(vars);
|
|
expect(result.text).toBe('\\n\\r\\t');
|
|
});
|
|
|
|
it('should produce valid JSON when used in raw request templates', () => {
|
|
// Simulate the actual use case: escaping variables before inserting into JSON template
|
|
const vars = {
|
|
prompt: 'Please write:\n"Hello"\nThank you',
|
|
guid: '12345',
|
|
count: 42,
|
|
};
|
|
const escaped = escapeJsonVariables(vars);
|
|
|
|
// Construct a JSON string using the escaped values
|
|
const jsonString = `{"user_input":"${escaped.prompt}","guid":"${escaped.guid}","count":${escaped.count}}`;
|
|
|
|
// Should be valid JSON
|
|
expect(() => JSON.parse(jsonString)).not.toThrow();
|
|
|
|
// Should parse back to correct values
|
|
const parsed = JSON.parse(jsonString);
|
|
expect(parsed.user_input).toBe('Please write:\n"Hello"\nThank you');
|
|
expect(parsed.guid).toBe('12345');
|
|
expect(parsed.count).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('processTextBody', () => {
|
|
it('should render templates in text bodies', () => {
|
|
const body = 'Hello {{name}}!';
|
|
const vars = { name: 'World' };
|
|
expect(processTextBody(body, vars)).toBe('Hello World!');
|
|
});
|
|
|
|
it('should handle rendering errors gracefully', () => {
|
|
const body = 'Hello {{ unclosed tag';
|
|
const vars = { name: 'World' };
|
|
expect(processTextBody(body, vars)).toBe(body); // Should return original
|
|
});
|
|
|
|
it('should handle null body gracefully', () => {
|
|
// @ts-ignore - Testing null input
|
|
expect(processTextBody(null, {})).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('should use default parser when no parser is provided', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ key: 'value' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toEqual({ key: 'value' });
|
|
});
|
|
|
|
it('should handle response transform returning an object', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
body: { key: 'value' },
|
|
transformResponse: (json: any, _text: string) => ({ custom: json.result }),
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toEqual({ custom: 'success' });
|
|
});
|
|
|
|
describe('file:// transform integration tests', () => {
|
|
it('should handle file:// response transform', async () => {
|
|
const mockParser = vi.fn((data: any) => ({ transformed: data.result }));
|
|
vi.mocked(importModule).mockResolvedValueOnce(mockParser);
|
|
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { key: 'value' },
|
|
transformResponse: 'file://custom-parser.js',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toEqual({ transformed: 'success' });
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('/mock/base/path', 'custom-parser.js'),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should handle file:// response transform with specific function name', async () => {
|
|
const mockParser = vi.fn((data: any) => data.customField);
|
|
vi.mocked(importModule).mockResolvedValueOnce(mockParser);
|
|
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { key: 'value' },
|
|
transformResponse: 'file://custom-parser.js:parseResponse',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ customField: 'parsed value' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
expect(result.output).toBe('parsed value');
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('/mock/base/path', 'custom-parser.js'),
|
|
'parseResponse',
|
|
);
|
|
});
|
|
|
|
it('should handle file:// request transform', async () => {
|
|
const mockTransform = vi.fn((prompt: string) => ({ transformed: prompt.toUpperCase() }));
|
|
vi.mocked(importModule).mockResolvedValueOnce(mockTransform);
|
|
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: 'value' },
|
|
transformRequest: 'file://transform.js',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test');
|
|
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('/mock/base/path', 'transform.js'),
|
|
undefined,
|
|
);
|
|
expect(mockTransform).toHaveBeenCalledWith('test', expect.any(Object), undefined);
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ key: 'value', transformed: 'TEST' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should handle file:// request transform with specific function name', async () => {
|
|
const mockTransform = vi.fn((prompt: string) => ({ custom: prompt }));
|
|
vi.mocked(importModule).mockResolvedValueOnce(mockTransform);
|
|
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: 'value' },
|
|
transformRequest: 'file://transform.js:myTransform',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('hello');
|
|
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
path.resolve('/mock/base/path', 'transform.js'),
|
|
'myTransform',
|
|
);
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
body: JSON.stringify({ key: 'value', custom: 'hello' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should throw error for malformed file:// response transform', async () => {
|
|
vi.mocked(importModule).mockResolvedValueOnce({});
|
|
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { key: 'value' },
|
|
transformResponse: 'file://invalid-parser.js',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await expect(provider.callApi('test prompt')).rejects.toThrow(/Transform module malformed/);
|
|
});
|
|
|
|
it('should throw error for malformed file:// request transform', async () => {
|
|
vi.mocked(importModule).mockResolvedValueOnce({});
|
|
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { key: 'value' },
|
|
transformRequest: 'file://invalid-transform.js',
|
|
},
|
|
});
|
|
|
|
await expect(provider.callApi('test prompt')).rejects.toThrow(/Transform module malformed/);
|
|
});
|
|
});
|
|
|
|
describe('getDefaultHeaders', () => {
|
|
it('should return empty object for GET requests', () => {
|
|
const provider = new HttpProvider(mockUrl, { config: { method: 'GET' } });
|
|
const result = provider['getDefaultHeaders'](null);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('should return application/json for object body', () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: { method: 'POST', body: { key: 'value' } },
|
|
});
|
|
const result = provider['getDefaultHeaders']({ key: 'value' });
|
|
expect(result).toEqual({ 'content-type': 'application/json' });
|
|
});
|
|
|
|
it('should return application/x-www-form-urlencoded for string body', () => {
|
|
const provider = new HttpProvider(mockUrl, { config: { method: 'POST', body: 'test' } });
|
|
const result = provider['getDefaultHeaders']('string body');
|
|
expect(result).toEqual({ 'content-type': 'application/x-www-form-urlencoded' });
|
|
});
|
|
});
|
|
|
|
describe('validateContentTypeAndBody', () => {
|
|
it('should not throw for valid content-type and body', () => {
|
|
const provider = new HttpProvider(mockUrl, { config: { body: 'test' } });
|
|
expect(() => {
|
|
provider['validateContentTypeAndBody'](
|
|
{ 'content-type': 'application/json' },
|
|
{ key: 'value' },
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should throw for non-json content-type with object body', () => {
|
|
const provider = new HttpProvider(mockUrl, { config: { body: 'test' } });
|
|
expect(() => {
|
|
provider['validateContentTypeAndBody'](
|
|
{ 'content-type': 'application/x-www-form-urlencoded' },
|
|
{ key: 'value' },
|
|
);
|
|
}).toThrow('Content-Type is not application/json, but body is an object or array');
|
|
});
|
|
});
|
|
|
|
describe('getHeaders', () => {
|
|
it('should combine default headers with config headers', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
headers: { 'X-Custom': '{{ prompt }}' },
|
|
body: 'test',
|
|
},
|
|
});
|
|
const result = await provider.getHeaders(
|
|
{ 'content-type': 'application/json' },
|
|
{ prompt: 'test' },
|
|
);
|
|
expect(result).toEqual({
|
|
'content-type': 'application/json',
|
|
'x-custom': 'test',
|
|
});
|
|
});
|
|
|
|
it('should render template strings in headers', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
headers: { 'X-Custom': '{{ prompt | upper }}' },
|
|
body: 'test',
|
|
},
|
|
});
|
|
const result = await provider.getHeaders({}, { prompt: 'test' });
|
|
expect(result).toEqual({
|
|
'x-custom': 'TEST',
|
|
});
|
|
});
|
|
|
|
it('should render environment variables in headers', async () => {
|
|
// Setup a provider with environment variables in headers
|
|
const provider = new HttpProvider('http://example.com', {
|
|
config: {
|
|
method: 'GET', // GET method doesn't require body
|
|
headers: {
|
|
'X-API-Key': '{{env.API_KEY}}',
|
|
Authorization: 'Bearer {{env.AUTH_TOKEN}}',
|
|
Cookie: 'SESSION={{env.SESSION_ID}}; XSRF={{env.XSRF}}',
|
|
},
|
|
},
|
|
});
|
|
|
|
const restoreEnv = mockProcessEnv({
|
|
API_KEY: 'test-api-key',
|
|
AUTH_TOKEN: 'test-auth-token',
|
|
SESSION_ID: 'test-session',
|
|
XSRF: 'test-xsrf',
|
|
});
|
|
try {
|
|
// Call getHeaders method
|
|
const result = await provider.getHeaders({}, { prompt: 'test', env: process.env });
|
|
|
|
// Verify environment variables are rendered correctly
|
|
expect(result).toEqual({
|
|
'x-api-key': 'test-api-key',
|
|
authorization: 'Bearer test-auth-token',
|
|
cookie: 'SESSION=test-session; XSRF=test-xsrf',
|
|
});
|
|
} finally {
|
|
restoreEnv();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should default to application/json for content-type if body is an object', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'X-Custom': '{{ prompt }}' },
|
|
body: { key: '{{ prompt }}' },
|
|
},
|
|
});
|
|
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: {
|
|
'content-type': 'application/json',
|
|
'x-custom': 'test prompt',
|
|
},
|
|
body: JSON.stringify({ key: 'test prompt' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should bypass fetch cache when sending structured multipart requests', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
multipart: {
|
|
parts: [
|
|
{
|
|
kind: 'field',
|
|
name: 'documentQuery',
|
|
value: '{{prompt}}',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
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',
|
|
body: expect.any(FormData),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
true,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should default to application/x-www-form-urlencoded for content-type if body is not an object', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: 'test',
|
|
},
|
|
});
|
|
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: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: 'test',
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should throw an error if the body is an object and the content-type is not application/json', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'text/plain' },
|
|
body: { key: 'value' },
|
|
},
|
|
});
|
|
|
|
await expect(provider.callApi('test prompt')).rejects.toThrow(
|
|
'Content-Type is not application/json, but body is an object or array',
|
|
);
|
|
});
|
|
|
|
describe('Authentication header sanitization', () => {
|
|
let debugSpy: MockInstance | undefined;
|
|
|
|
afterEach(() => {
|
|
debugSpy?.mockRestore();
|
|
debugSpy = undefined;
|
|
});
|
|
|
|
it('should redact authentication headers in metadata', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: 'Bearer secret-token',
|
|
'x-api-key': 'api-key-12345',
|
|
cookie: 'session=abc123; other=value',
|
|
'x-custom-header': 'should-remain',
|
|
'cache-control': 'no-cache',
|
|
},
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
// Check that auth headers are redacted but other headers remain unchanged
|
|
expect(result.metadata?.http?.headers).toEqual({
|
|
'content-type': 'application/json',
|
|
authorization: '[REDACTED]',
|
|
'x-api-key': '[REDACTED]',
|
|
cookie: '[REDACTED]',
|
|
'x-custom-header': 'should-remain',
|
|
'cache-control': 'no-cache',
|
|
});
|
|
});
|
|
|
|
it('should redact various authentication header patterns', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
headers: {
|
|
Authorization: 'Bearer token',
|
|
'X-API-KEY': 'key123',
|
|
'API-Key': 'key456',
|
|
'X-Auth-Token': 'auth789',
|
|
'Access-Token': 'access123',
|
|
'X-Secret': 'secret456',
|
|
Token: 'token789',
|
|
ApiKey: 'apikey123',
|
|
Password: 'pass456',
|
|
Cookie: 'session=xyz',
|
|
'X-CSRF-Token': 'csrf123',
|
|
'Session-Id': 'session456',
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'User-Agent': 'test-agent',
|
|
},
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
// Sensitive headers should be redacted, others remain unchanged
|
|
expect(result.metadata?.http?.headers).toEqual({
|
|
Authorization: '[REDACTED]',
|
|
'X-API-KEY': '[REDACTED]',
|
|
'API-Key': '[REDACTED]',
|
|
'X-Auth-Token': '[REDACTED]',
|
|
'Access-Token': '[REDACTED]',
|
|
'X-Secret': '[REDACTED]',
|
|
Token: '[REDACTED]',
|
|
ApiKey: '[REDACTED]',
|
|
Password: '[REDACTED]',
|
|
Cookie: '[REDACTED]',
|
|
'X-CSRF-Token': '[REDACTED]',
|
|
'Session-Id': '[REDACTED]',
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'User-Agent': 'test-agent',
|
|
});
|
|
});
|
|
|
|
it('should handle missing or undefined headers gracefully', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
headers: undefined,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
// Should return undefined when headers are undefined
|
|
expect(result.metadata?.http?.headers).toBeUndefined();
|
|
});
|
|
|
|
it('should redact auth headers in raw request mode with debug context', async () => {
|
|
const rawRequest = dedent`
|
|
POST /api/data?api_key=secret-query-value HTTP/1.0
|
|
Host: example.com
|
|
Authorization: Bearer raw-request-token-12345678901234567890
|
|
Content-Type: application/json
|
|
User-Agent: TestAgent/1.0
|
|
|
|
{"password":"plain-secret","message":"ok"}
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: {
|
|
authorization: 'Bearer token',
|
|
'x-api-key': 'secret',
|
|
'content-type': 'application/json',
|
|
etag: 'W/"123"',
|
|
},
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt', {
|
|
debug: true,
|
|
vars: {},
|
|
prompt: { raw: 'test prompt', label: 'test' },
|
|
});
|
|
|
|
// In debug mode, headers should still have auth info redacted
|
|
expect(result.metadata?.headers).toEqual({
|
|
authorization: '[REDACTED]',
|
|
'x-api-key': '[REDACTED]',
|
|
'content-type': 'application/json',
|
|
etag: 'W/"123"',
|
|
});
|
|
expect(result.metadata?.http?.requestHeaders).toEqual({
|
|
authorization: '[REDACTED]',
|
|
host: 'example.com',
|
|
'content-type': 'application/json',
|
|
'user-agent': 'TestAgent/1.0',
|
|
});
|
|
expect(result.metadata?.finalRequestBody).toBe('{"password":"[REDACTED]","message":"ok"}');
|
|
expect(result.metadata?.transformedRequest).toContain('/api/data?api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).toContain('HTTP/1.0');
|
|
expect(result.metadata?.transformedRequest).not.toContain('secret-query-value');
|
|
expect(result.metadata?.transformedRequest).not.toContain('raw-request-token');
|
|
expect(result.metadata?.transformedRequest).not.toContain('plain-secret');
|
|
});
|
|
|
|
it('should redact multipart raw request bodies in debug metadata', async () => {
|
|
const boundary = '----WebKitFormBoundaryPromptFooABC123';
|
|
const opaqueToken = 'A'.repeat(64);
|
|
const rawRequest = dedent`
|
|
POST /upload HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: multipart/form-data; boundary=${boundary}
|
|
|
|
--${boundary}
|
|
Content-Disposition: form-data; name="username"
|
|
|
|
alice
|
|
--${boundary}
|
|
Content-Disposition: form-data; name=password
|
|
|
|
plain-secret
|
|
--${boundary}
|
|
Content-Disposition: form-data; name="apiKey"
|
|
|
|
sk-123456789012345678901234567890
|
|
--${boundary}
|
|
Content-Disposition: form-data; name="note"
|
|
|
|
${opaqueToken}
|
|
--${boundary}--
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt', {
|
|
debug: true,
|
|
vars: {},
|
|
prompt: { raw: 'test prompt', label: 'test' },
|
|
});
|
|
|
|
expect(result.metadata?.transformedRequest).toContain(boundary);
|
|
expect(result.metadata?.transformedRequest).toContain('name="username"');
|
|
expect(result.metadata?.transformedRequest).toContain('alice');
|
|
expect(result.metadata?.transformedRequest).toContain('name=password');
|
|
expect(result.metadata?.transformedRequest).toContain('[REDACTED]');
|
|
expect(result.metadata?.transformedRequest).not.toContain('plain-secret');
|
|
expect(result.metadata?.transformedRequest).not.toContain(
|
|
'sk-123456789012345678901234567890',
|
|
);
|
|
expect(result.metadata?.transformedRequest).not.toContain(opaqueToken);
|
|
expect(result.metadata?.finalRequestBody).not.toContain('plain-secret');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('sk-123456789012345678901234567890');
|
|
expect(result.metadata?.finalRequestBody).not.toContain(opaqueToken);
|
|
});
|
|
|
|
it('should redact malformed multipart raw request bodies without throwing', async () => {
|
|
const rawRequest = dedent`
|
|
POST /upload HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: multipart/form-data; boundary=MissingBoundary
|
|
|
|
username=alice&password=plain-secret&api_key=sk-123456789012345678901234567890
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: rawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt', {
|
|
debug: true,
|
|
vars: {},
|
|
prompt: { raw: 'test prompt', label: 'test' },
|
|
});
|
|
|
|
expect(result.metadata?.transformedRequest).toContain('username=alice');
|
|
expect(result.metadata?.transformedRequest).toContain('password=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).toContain('api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).not.toContain('plain-secret');
|
|
expect(result.metadata?.transformedRequest).not.toContain(
|
|
'sk-123456789012345678901234567890',
|
|
);
|
|
expect(result.metadata?.finalRequestBody).toContain('password=%5BREDACTED%5D');
|
|
expect(result.metadata?.finalRequestBody).toContain('api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('plain-secret');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('sk-123456789012345678901234567890');
|
|
});
|
|
|
|
it('should redact transformed raw request strings in debug metadata', async () => {
|
|
debugSpy = vi.spyOn(logger, 'debug');
|
|
const transformedRawRequest = dedent`
|
|
GET /api/data?api_key=transform-query-secret HTTP/1.1
|
|
Host: example.com
|
|
Authorization: Bearer raw-transform-token-12345678901234567890
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"apiKey": "sk-123456789012345678901234567890",
|
|
"password": "plain-secret",
|
|
"message": "ok"
|
|
}
|
|
`;
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: dedent`
|
|
POST /api/data HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: text/plain
|
|
|
|
{{prompt}}
|
|
`,
|
|
transformRequest: () => transformedRawRequest,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const result = await provider.callApi('test prompt', {
|
|
debug: true,
|
|
vars: {},
|
|
prompt: { raw: 'test prompt', label: 'test' },
|
|
});
|
|
|
|
expect(result.metadata?.transformedRequest).toContain('/api/data?api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).toContain('authorization: [REDACTED]');
|
|
expect(result.metadata?.transformedRequest).toContain('"apiKey":"[REDACTED]"');
|
|
expect(result.metadata?.transformedRequest).toContain('"password":"[REDACTED]"');
|
|
expect(result.metadata?.transformedRequest).not.toContain('transform-query-secret');
|
|
expect(result.metadata?.transformedRequest).not.toContain('raw-transform-token');
|
|
expect(result.metadata?.transformedRequest).not.toContain('plain-secret');
|
|
expect(result.metadata?.transformedRequest).not.toContain(
|
|
'sk-123456789012345678901234567890',
|
|
);
|
|
expect(result.metadata?.finalRequestBody).toContain('"apiKey":"[REDACTED]"');
|
|
expect(result.metadata?.finalRequestBody).toContain('"password":"[REDACTED]"');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('raw-transform-token');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('plain-secret');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('sk-123456789012345678901234567890');
|
|
const debugOutput = JSON.stringify(debugSpy.mock.calls);
|
|
expect(debugOutput).not.toContain('transform-query-secret');
|
|
expect(debugOutput).not.toContain('raw-transform-token');
|
|
expect(debugOutput).not.toContain('plain-secret');
|
|
expect(debugOutput).not.toContain('sk-123456789012345678901234567890');
|
|
});
|
|
|
|
it('should redact form-urlencoded transformed requests in debug metadata', async () => {
|
|
debugSpy = vi.spyOn(logger, 'debug');
|
|
const formBody =
|
|
'username=alice&password=plain-secret&api_key=sk-123456789012345678901234567890';
|
|
const provider = new HttpProvider('http://example.com/api', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: '{{ prompt }}',
|
|
transformRequest: () => formBody,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const result = await provider.callApi('username=bob&password=original-secret', {
|
|
debug: true,
|
|
vars: {},
|
|
prompt: { raw: 'username=bob&password=original-secret', label: 'test' },
|
|
});
|
|
|
|
expect(result.metadata?.transformedRequest).toContain('username=alice');
|
|
expect(result.metadata?.transformedRequest).toContain('password=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).toContain('api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).not.toContain('plain-secret');
|
|
expect(result.metadata?.transformedRequest).not.toContain(
|
|
'sk-123456789012345678901234567890',
|
|
);
|
|
expect(result.metadata?.finalRequestBody).toContain('username=alice');
|
|
expect(result.metadata?.finalRequestBody).toContain('password=%5BREDACTED%5D');
|
|
expect(result.metadata?.finalRequestBody).toContain('api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('plain-secret');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('sk-123456789012345678901234567890');
|
|
const debugOutput = JSON.stringify(debugSpy.mock.calls);
|
|
expect(debugOutput).not.toContain('original-secret');
|
|
expect(debugOutput).not.toContain('plain-secret');
|
|
expect(debugOutput).not.toContain('sk-123456789012345678901234567890');
|
|
});
|
|
|
|
it('should redact form-urlencoded transformed raw request debug logs', async () => {
|
|
debugSpy = vi.spyOn(logger, 'debug');
|
|
const formBody =
|
|
'username=alice&password=plain-secret&api_key=sk-123456789012345678901234567890';
|
|
const provider = new HttpProvider('http', {
|
|
config: {
|
|
request: dedent`
|
|
POST /api/data HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
{{ prompt }}
|
|
`,
|
|
transformRequest: () => formBody,
|
|
transformResponse: (data: any) => data,
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: JSON.stringify({ result: 'success' }),
|
|
cached: false,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const result = await provider.callApi('username=bob&password=original-secret', {
|
|
debug: true,
|
|
vars: {},
|
|
prompt: { raw: 'username=bob&password=original-secret', label: 'test' },
|
|
});
|
|
|
|
expect(result.metadata?.transformedRequest).toContain('username=alice');
|
|
expect(result.metadata?.transformedRequest).toContain('password=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).toContain('api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.transformedRequest).not.toContain('plain-secret');
|
|
expect(result.metadata?.transformedRequest).not.toContain(
|
|
'sk-123456789012345678901234567890',
|
|
);
|
|
expect(result.metadata?.finalRequestBody).toContain('username=alice');
|
|
expect(result.metadata?.finalRequestBody).toContain('password=%5BREDACTED%5D');
|
|
expect(result.metadata?.finalRequestBody).toContain('api_key=%5BREDACTED%5D');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('plain-secret');
|
|
expect(result.metadata?.finalRequestBody).not.toContain('sk-123456789012345678901234567890');
|
|
const debugOutput = JSON.stringify(debugSpy.mock.calls);
|
|
expect(debugOutput).not.toContain('original-secret');
|
|
expect(debugOutput).not.toContain('plain-secret');
|
|
expect(debugOutput).not.toContain('sk-123456789012345678901234567890');
|
|
});
|
|
|
|
it('should handle case-insensitive header matching', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
headers: {
|
|
AUTHORIZATION: 'Bearer TOKEN',
|
|
'x-ApI-kEy': 'KEY',
|
|
'Content-TYPE': 'application/json',
|
|
'X-Request-ID': 'req-123',
|
|
},
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test prompt');
|
|
|
|
// Auth headers should be redacted regardless of case
|
|
expect(result.metadata?.http?.headers).toEqual({
|
|
AUTHORIZATION: '[REDACTED]',
|
|
'x-ApI-kEy': '[REDACTED]',
|
|
'Content-TYPE': 'application/json',
|
|
'X-Request-ID': 'req-123',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Content-Type and body handling', () => {
|
|
it('should render string body when content-type is not set', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: 'Hello {{ prompt }}',
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('world');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: 'Hello world',
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should default to JSON when content-type is not set and body is an object', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { key: 'test' },
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: 'response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ key: 'test' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should render object body when content-type is application/json', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: '{{ prompt }}' },
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ key: 'test' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should render a stringified object body when content-type is application/json', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: '{{ prompt }}' }),
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test');
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ key: 'test' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should render nested object variables correctly when content-type is application/json', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: {
|
|
details: {
|
|
names: '{{ names | dump }}',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const vars = {
|
|
names: [
|
|
{ firstName: 'Jane', lastName: 'Smith' },
|
|
{ firstName: 'John', lastName: 'Doe' },
|
|
],
|
|
};
|
|
|
|
await provider.callApi('test', { vars, prompt: { raw: 'test', label: 'test' } });
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
details: {
|
|
names: vars.names,
|
|
},
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should render nested array variables correctly when content-type is application/json', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: [
|
|
{
|
|
id: 1,
|
|
details: {
|
|
names: '{{ names | dump }}',
|
|
},
|
|
},
|
|
{
|
|
id: 2,
|
|
details: {
|
|
names: '{{ names | dump }}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: 'response',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const vars = {
|
|
names: [
|
|
{ firstName: 'Jane', lastName: 'Smith' },
|
|
{ firstName: 'John', lastName: 'Doe' },
|
|
],
|
|
};
|
|
|
|
await provider.callApi('test', { vars, prompt: { raw: 'test', label: 'test' } });
|
|
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
mockUrl,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify([
|
|
{
|
|
id: 1,
|
|
details: {
|
|
names: vars.names,
|
|
},
|
|
},
|
|
{
|
|
id: 2,
|
|
details: {
|
|
names: vars.names,
|
|
},
|
|
},
|
|
]),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deprecated responseParser handling', () => {
|
|
it('should use responseParser when transformResponse is not set', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: '{{ prompt }}' },
|
|
responseParser: (data: any) => ({ chat_history: data.result }),
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test');
|
|
|
|
expect(result).toMatchObject({
|
|
output: { chat_history: 'success' },
|
|
raw: JSON.stringify({ result: 'success' }),
|
|
metadata: {
|
|
http: { status: 200, statusText: 'OK', headers: undefined },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should prefer transformResponse over responseParser when both are set', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: '{{ prompt }}' },
|
|
responseParser: (_data: any) => ({ chat_history: 'from responseParser' }),
|
|
transformResponse: (_data: any) => ({ chat_history: 'from transformResponse' }),
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test');
|
|
|
|
expect(result).toMatchObject({
|
|
output: { chat_history: 'from transformResponse' },
|
|
raw: JSON.stringify({ result: 'success' }),
|
|
metadata: {
|
|
http: { status: 200, statusText: 'OK', headers: undefined },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle string-based responseParser when transformResponse is not set', async () => {
|
|
const provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: '{{ prompt }}' },
|
|
responseParser: 'json.result',
|
|
},
|
|
});
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
const result = await provider.callApi('test');
|
|
|
|
expect(result).toMatchObject({
|
|
output: 'success',
|
|
raw: JSON.stringify({ result: 'success' }),
|
|
metadata: {
|
|
http: { status: 200, statusText: 'OK', headers: undefined },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should respect maxRetries configuration', async () => {
|
|
provider = new HttpProvider(mockUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: { key: '{{ prompt }}' },
|
|
maxRetries: 2,
|
|
},
|
|
});
|
|
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: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ key: 'test prompt' }),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
2,
|
|
);
|
|
});
|
|
|
|
it('should handle query parameters correctly when the URL already has query parameters', async () => {
|
|
const urlWithQueryParams = 'http://example.com/api?existing=param';
|
|
provider = new HttpProvider(urlWithQueryParams, {
|
|
config: {
|
|
method: 'GET',
|
|
queryParams: {
|
|
additional: 'parameter',
|
|
another: 'value',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
// URL should contain both the existing and new query parameters
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
expect.stringMatching(
|
|
/http:\/\/example\.com\/api\?existing=param&additional=parameter&another=value/,
|
|
),
|
|
expect.any(Object),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should handle URL construction fallback for potentially malformed URLs', async () => {
|
|
// Create a URL with variable that when rendered doesn't fully qualify as a URL
|
|
const malformedUrl = 'relative/path/{{var}}';
|
|
|
|
provider = new HttpProvider(malformedUrl, {
|
|
config: {
|
|
method: 'GET',
|
|
queryParams: {
|
|
param: 'value',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt', {
|
|
prompt: { raw: 'test prompt', label: 'test' },
|
|
vars: { var: 'test' },
|
|
});
|
|
|
|
// Should use the fallback mechanism to append query parameters
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'relative/path/test?param=value',
|
|
expect.any(Object),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('urlEncodeRawRequestPath', () => {
|
|
it('should not modify request with no query parameters', () => {
|
|
const rawRequest = 'GET /api/data HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe(rawRequest);
|
|
});
|
|
|
|
it('should not modify request with simple query parameters', () => {
|
|
const rawRequest = 'GET /api/data?key=value HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe(rawRequest);
|
|
});
|
|
|
|
it('should encode URL with spaces in query parameters', () => {
|
|
const rawRequest = 'GET /api/data?query=hello world HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe('GET /api/data?query=hello%20world HTTP/1.1');
|
|
});
|
|
|
|
it('should encode URL with already percent-encoded characters', () => {
|
|
const rawRequest = 'GET /api/data?query=already%20encoded HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe('GET /api/data?query=already%20encoded HTTP/1.1');
|
|
});
|
|
|
|
it('should not leak sensitive query values when logging URL encoding', () => {
|
|
const debugSpy = vi.spyOn(logger, 'debug');
|
|
try {
|
|
const rawRequest = 'GET /api/data?api_key=secret%zz&query=hello world HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
|
|
expect(result).toBe('GET /api/data?api_key=secret%zz&query=hello%20world HTTP/1.1');
|
|
const debugOutput = debugSpy.mock.calls.flat().join('\n');
|
|
expect(debugOutput).toContain('api_key=%5BREDACTED%5D');
|
|
expect(debugOutput).not.toContain('secret%zz');
|
|
} finally {
|
|
debugSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('should not leak secret-looking query values with non-sensitive parameter names', () => {
|
|
const debugSpy = vi.spyOn(logger, 'debug');
|
|
try {
|
|
const rawRequest =
|
|
'GET /api/data?cursor=sk-123456789012345678901234567890&query=hello world HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
|
|
expect(result).toBe(
|
|
'GET /api/data?cursor=sk-123456789012345678901234567890&query=hello%20world HTTP/1.1',
|
|
);
|
|
const debugOutput = debugSpy.mock.calls.flat().join('\n');
|
|
expect(debugOutput).toContain('cursor=%5BREDACTED%5D');
|
|
expect(debugOutput).not.toContain('sk-123456789012345678901234567890');
|
|
} finally {
|
|
debugSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('should throw error when modifying malformed request with no URL', () => {
|
|
const rawRequest = 'GET HTTP/1.1';
|
|
expect(() => urlEncodeRawRequestPath(rawRequest)).toThrow(/not valid/);
|
|
});
|
|
|
|
it('should not leak query credentials from a malformed first line in errors or logs', () => {
|
|
const errorSpy = vi.spyOn(logger, 'error');
|
|
try {
|
|
// A non-HTTP protocol token triggers the protocol-invalid path; the
|
|
// request target carries a secret in its query string.
|
|
const rawRequest = 'GET /v1/chat?api_key=sk-realsecret1234567890¬e=foo FTP/1.1';
|
|
expect(() => urlEncodeRawRequestPath(rawRequest)).toThrow(/protocol is not valid/);
|
|
|
|
const thrownMessage = (() => {
|
|
try {
|
|
urlEncodeRawRequestPath(rawRequest);
|
|
return '';
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
})();
|
|
const loggedOutput = errorSpy.mock.calls.flat().join('\n');
|
|
|
|
// The secret never appears in the thrown message or the error log.
|
|
expect(thrownMessage).not.toContain('sk-realsecret1234567890');
|
|
expect(loggedOutput).not.toContain('sk-realsecret1234567890');
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('should not warn when encoding a valid raw request line', () => {
|
|
// The first line is not a URL, so eagerly sanitizing it would console.warn on
|
|
// every valid raw request. The sanitize must be deferred to the error branches.
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const result = urlEncodeRawRequestPath('GET /api/data?q=hello world HTTP/1.1');
|
|
expect(result).toBe('GET /api/data?q=hello%20world HTTP/1.1');
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('should handle complete raw request with headers', () => {
|
|
const rawRequest = dedent`
|
|
GET /summarized?topic=hello world&start=01/01/2025&end=01/07/2025&auto_extract_keywords=false HTTP/2
|
|
Host: foo.bar.com
|
|
User-Agent: curl/8.7.1
|
|
Accept: application/json
|
|
`;
|
|
const expected = dedent`
|
|
GET /summarized?topic=hello%20world&start=01/01/2025&end=01/07/2025&auto_extract_keywords=false HTTP/2
|
|
Host: foo.bar.com
|
|
User-Agent: curl/8.7.1
|
|
Accept: application/json
|
|
`;
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it('should handle POST request with JSON body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit?param=hello world HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"key": "value with spaces", "date": "01/01/2025"}
|
|
`;
|
|
const expected = dedent`
|
|
POST /api/submit?param=hello%20world HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"key": "value with spaces", "date": "01/01/2025"}
|
|
`;
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it('should handle URL with path containing spaces', () => {
|
|
const rawRequest = 'GET /path with spaces/resource HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe('GET /path%20with%20spaces/resource HTTP/1.1');
|
|
});
|
|
|
|
it('should handle URL with special characters in path and query', () => {
|
|
const rawRequest = 'GET /path/with [brackets]?param=value&special=a+b+c HTTP/1.1';
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe('GET /path/with%20[brackets]?param=value&special=a+b+c HTTP/1.1');
|
|
});
|
|
|
|
it('should handle completely misformed first line', () => {
|
|
const rawRequest = 'This is not a valid HTTP request line';
|
|
expect(() => urlEncodeRawRequestPath(rawRequest)).toThrow(/not valid/);
|
|
});
|
|
|
|
it('should handle request with no HTTP protocol version', () => {
|
|
const rawRequest = 'GET /api/data?query=test';
|
|
expect(() => urlEncodeRawRequestPath(rawRequest)).toThrow(/not valid/);
|
|
});
|
|
|
|
it('should handle request with different HTTP methods', () => {
|
|
const methods = ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
|
|
|
|
for (const method of methods) {
|
|
const rawRequest = dedent`
|
|
${method} /api/submit?param=hello world HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"key": "value with spaces", "date": "01/01/2025"}
|
|
`;
|
|
const expected = dedent`
|
|
${method} /api/submit?param=hello%20world HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"key": "value with spaces", "date": "01/01/2025"}
|
|
`;
|
|
const result = urlEncodeRawRequestPath(rawRequest);
|
|
expect(result).toBe(expected);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('extractBodyFromRawRequest', () => {
|
|
it('should extract body from a simple POST request', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"key": "value"}
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe('{"key": "value"}');
|
|
});
|
|
|
|
it('should extract multipart/form-data body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/upload HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: multipart/form-data; boundary=----Boundary123
|
|
|
|
------Boundary123
|
|
Content-Disposition: form-data; name="field1"
|
|
|
|
value1
|
|
------Boundary123--
|
|
`;
|
|
const body = extractBodyFromRawRequest(rawRequest);
|
|
expect(body).toContain('------Boundary123');
|
|
expect(body).toContain('Content-Disposition: form-data; name="field1"');
|
|
expect(body).toContain('value1');
|
|
expect(body).toContain('------Boundary123--');
|
|
});
|
|
|
|
it('should extract x-www-form-urlencoded body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
field1=value1&field2=value2
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe('field1=value1&field2=value2');
|
|
});
|
|
|
|
it('should return undefined for GET request without body', () => {
|
|
const rawRequest = dedent`
|
|
GET /api/data HTTP/1.1
|
|
Host: example.com
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for request with empty body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBeUndefined();
|
|
});
|
|
|
|
it('should handle body containing \\r\\n\\r\\n sequence', () => {
|
|
const rawRequest = 'POST /api/submit HTTP/1.1\r\nHost: example.com\r\n\r\nline1\r\n\r\nline2';
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe('line1\r\n\r\nline2');
|
|
});
|
|
|
|
it('should normalize mixed line endings', () => {
|
|
const rawRequest = 'POST /api/submit HTTP/1.1\nHost: example.com\n\nbody content';
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe('body content');
|
|
});
|
|
|
|
it('should trim leading and trailing whitespace from body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
body with whitespace
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe('body with whitespace');
|
|
});
|
|
|
|
it('should handle special characters in body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
|
|
{"emoji": "🎉", "unicode": "日本語", "ampersand": "&"}
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe(
|
|
'{"emoji": "🎉", "unicode": "日本語", "ampersand": "&"}',
|
|
);
|
|
});
|
|
|
|
it('should handle multiple headers before body', () => {
|
|
const rawRequest = dedent`
|
|
POST /api/submit HTTP/1.1
|
|
Host: example.com
|
|
Content-Type: application/json
|
|
Authorization: Bearer token123
|
|
X-Custom-Header: custom-value
|
|
Accept: application/json
|
|
|
|
{"data": "test"}
|
|
`;
|
|
expect(extractBodyFromRawRequest(rawRequest)).toBe('{"data": "test"}');
|
|
});
|
|
});
|
|
|
|
describe('Body file resolution', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should resolve file:// references in body configuration', () => {
|
|
const mockTransactions = [
|
|
{ id: '1', amount: '100.50', date: '2025-06-01' },
|
|
{ id: '2', amount: '250.75', date: '2025-06-02' },
|
|
];
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return {
|
|
query: '{{prompt}}',
|
|
date: '2025-06-03T22:01:13.797Z',
|
|
transactions: mockTransactions,
|
|
};
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: {
|
|
query: '{{prompt}}',
|
|
date: '2025-06-03T22:01:13.797Z',
|
|
transactions: 'file://./test_data/transactions.csv',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Verify maybeLoadConfigFromExternalFile was called with the body
|
|
expect(maybeLoadConfigFromExternalFile).toHaveBeenCalledWith({
|
|
query: '{{prompt}}',
|
|
date: '2025-06-03T22:01:13.797Z',
|
|
transactions: 'file://./test_data/transactions.csv',
|
|
});
|
|
|
|
// The provider should have the resolved config
|
|
expect(provider['config'].body).toEqual({
|
|
query: '{{prompt}}',
|
|
date: '2025-06-03T22:01:13.797Z',
|
|
transactions: mockTransactions,
|
|
});
|
|
});
|
|
|
|
it('should resolve nested file:// references in body configuration', () => {
|
|
const mockTransactions = [
|
|
{ id: '1', amount: '100.50' },
|
|
{ id: '2', amount: '250.75' },
|
|
];
|
|
const mockConfig = {
|
|
api_key: 'test-key-123',
|
|
timeout: 5000,
|
|
};
|
|
const mockUsers = [
|
|
{ name: 'John', email: 'john@example.com' },
|
|
{ name: 'Jane', email: 'jane@example.com' },
|
|
];
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return {
|
|
query: '{{prompt}}',
|
|
data: {
|
|
transactions: mockTransactions,
|
|
settings: mockConfig,
|
|
nested: {
|
|
users: mockUsers,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: {
|
|
query: '{{prompt}}',
|
|
data: {
|
|
transactions: 'file://./transactions.csv',
|
|
settings: 'file://./config.json',
|
|
nested: {
|
|
users: 'file://./users.csv',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Verify the nested structure was resolved
|
|
expect(provider['config'].body).toEqual({
|
|
query: '{{prompt}}',
|
|
data: {
|
|
transactions: mockTransactions,
|
|
settings: mockConfig,
|
|
nested: {
|
|
users: mockUsers,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should resolve file:// references in arrays', () => {
|
|
const mockConfig = {
|
|
api_key: 'test-key-123',
|
|
timeout: 5000,
|
|
};
|
|
const mockUsers = [{ name: 'John', email: 'john@example.com' }];
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return [
|
|
'regular string',
|
|
mockConfig,
|
|
{
|
|
inside_array: mockUsers,
|
|
},
|
|
];
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: [
|
|
'regular string',
|
|
'file://./config.json',
|
|
{
|
|
inside_array: 'file://./users.csv',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
// Verify arrays with file references were resolved
|
|
expect(provider['config'].body).toEqual([
|
|
'regular string',
|
|
mockConfig,
|
|
{
|
|
inside_array: mockUsers,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should not affect body when no file:// references are present', () => {
|
|
const originalBody = {
|
|
query: '{{prompt}}',
|
|
regular: 'data',
|
|
nested: {
|
|
value: 123,
|
|
array: ['a', 'b', 'c'],
|
|
},
|
|
};
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return originalBody;
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: originalBody,
|
|
},
|
|
});
|
|
|
|
// Body should remain unchanged
|
|
expect(provider['config'].body).toEqual(originalBody);
|
|
});
|
|
|
|
it('should work with string body containing file:// reference', () => {
|
|
const mockContent = 'This is the content from the file';
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return mockContent;
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body: 'file://./content.txt',
|
|
},
|
|
});
|
|
|
|
// String body should be resolved to file content
|
|
expect(provider['config'].body).toBe(mockContent);
|
|
});
|
|
|
|
it('should use resolved body in API calls', async () => {
|
|
const mockTransactions = [
|
|
{ id: '1', amount: '100.50' },
|
|
{ id: '2', amount: '250.75' },
|
|
];
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return {
|
|
query: '{{prompt}}',
|
|
transactions: mockTransactions,
|
|
};
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: {
|
|
query: '{{prompt}}',
|
|
transactions: 'file://./transactions.csv',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: JSON.stringify({ result: 'success' }),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
// Verify the fetch was called with the resolved body
|
|
// Note: processJsonBody may parse JSON strings, so check the actual call
|
|
expect(fetchWithCache).toHaveBeenCalled();
|
|
|
|
const actualCall = vi.mocked(fetchWithCache).mock.calls[0];
|
|
expect(actualCall).toBeDefined();
|
|
expect(actualCall[0]).toBe('http://test.com');
|
|
|
|
const requestOptions = actualCall[1];
|
|
expect(requestOptions).toBeDefined();
|
|
expect(requestOptions!.method).toBe('POST');
|
|
expect(requestOptions!.headers).toEqual({ 'content-type': 'application/json' });
|
|
|
|
// Parse the actual body to verify it contains the right data
|
|
const bodyStr = requestOptions!.body as string;
|
|
const bodyObj = JSON.parse(bodyStr);
|
|
expect(bodyObj.query).toBe('test prompt');
|
|
expect(bodyObj.transactions).toBeDefined();
|
|
expect(bodyObj.transactions.length).toBe(2);
|
|
// The transactions are there, whether as strings or numbers
|
|
expect(bodyObj.transactions[0].id).toBeDefined();
|
|
expect(bodyObj.transactions[1].id).toBeDefined();
|
|
});
|
|
|
|
it('should handle GET requests without body file resolution', () => {
|
|
// maybeLoadConfigFromExternalFile should not be called for GET requests without body
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockClear();
|
|
|
|
new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
});
|
|
|
|
// Should not call maybeLoadConfigFromExternalFile since there's no body
|
|
expect(maybeLoadConfigFromExternalFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle complex nested file resolutions with mixed content', () => {
|
|
const mockData = {
|
|
simple: 'value',
|
|
fileRef: { loaded: 'from file' },
|
|
nested: {
|
|
another: 'regular',
|
|
fileData: [1, 2, 3],
|
|
deeper: {
|
|
moreFiles: { data: 'loaded' },
|
|
},
|
|
},
|
|
arrayWithFiles: ['string', { fromFile: true }, ['nested', 'array']],
|
|
};
|
|
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return mockData;
|
|
});
|
|
|
|
const provider = new HttpProvider('http://test.com', {
|
|
config: {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: {
|
|
simple: 'value',
|
|
fileRef: 'file://./data.json',
|
|
nested: {
|
|
another: 'regular',
|
|
fileData: 'file://./numbers.json',
|
|
deeper: {
|
|
moreFiles: 'file://./more.json',
|
|
},
|
|
},
|
|
arrayWithFiles: ['string', 'file://./object.json', ['nested', 'array']],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(provider['config'].body).toEqual(mockData);
|
|
});
|
|
});
|
|
|
|
describe('HttpProvider - Sanitization', () => {
|
|
const testUrl = 'http://example.com/api';
|
|
let loggerDebugSpy: MockInstance;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loggerDebugSpy = vi.spyOn(logger, 'debug');
|
|
});
|
|
|
|
afterEach(() => {
|
|
loggerDebugSpy.mockRestore();
|
|
});
|
|
|
|
it('should sanitize pfxPassword in debug logs', async () => {
|
|
const provider = new HttpProvider(testUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { test: 'value' },
|
|
// Don't include signatureAuth to avoid signature generation errors
|
|
headers: {
|
|
'X-Custom': 'test-header',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Mock the sanitizeConfigForLogging function by spying on the actual config used in the log
|
|
const mockResponse = {
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
// Instead of testing pfxPassword directly, let's test a working scenario
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Calling http://example.com/api with config'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('should sanitize Authorization header in debug logs', async () => {
|
|
// Mock the file resolution to return a simple body to avoid conflicts
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return {
|
|
simple: 'test-value',
|
|
};
|
|
});
|
|
|
|
const provider = new HttpProvider(testUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { simple: 'test-value' },
|
|
headers: {
|
|
Authorization: 'Bearer secret-token-12345',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
// Verify the logger was called (actual sanitization happens internally in logger)
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Calling'),
|
|
expect.objectContaining({ config: expect.any(Object) }),
|
|
);
|
|
});
|
|
|
|
it('should sanitize multiple credential fields', async () => {
|
|
// Simplified test without signature auth to avoid certificate issues
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return {
|
|
simple: 'test-value',
|
|
};
|
|
});
|
|
|
|
const provider = new HttpProvider(testUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { simple: 'test-value' },
|
|
headers: {
|
|
Authorization: 'Bearer token-123',
|
|
'X-API-Key': 'api-key-456',
|
|
},
|
|
apiKey: 'main-api-key-789',
|
|
token: 'bearer-token-000',
|
|
password: 'config-password-111',
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
// Verify the logger was called (actual sanitization happens internally in logger)
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Calling'),
|
|
expect.objectContaining({ config: expect.any(Object) }),
|
|
);
|
|
});
|
|
|
|
it('should preserve non-sensitive fields', async () => {
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(function () {
|
|
return {
|
|
simple: 'test-value',
|
|
};
|
|
});
|
|
|
|
const provider = new HttpProvider(testUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
body: { simple: 'test-value' },
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'test-agent',
|
|
},
|
|
timeout: 5000,
|
|
maxRetries: 3,
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
const debugCall = loggerDebugSpy.mock.calls.find(
|
|
(call: any) => call[0]?.includes('Calling') && call[0]?.includes('with config'),
|
|
);
|
|
expect(debugCall).toBeDefined();
|
|
|
|
const context = debugCall?.[1];
|
|
const contextStr = JSON.stringify(context);
|
|
expect(contextStr).toContain('"content-type":"application/json"'); // lowercase
|
|
expect(contextStr).toContain('"user-agent":"test-agent"'); // lowercase
|
|
// Note: timeout and maxRetries are not included in the rendered config that gets logged
|
|
expect(contextStr).not.toContain('[REDACTED]');
|
|
});
|
|
|
|
it('should sanitize form-urlencoded bodies with raw spaces in debug logs', async () => {
|
|
const provider = new HttpProvider(testUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: 'username=alice&password=plain-secret¬e=hello world',
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
});
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
const debugCall = loggerDebugSpy.mock.calls.find(
|
|
(call: any) => call[0]?.includes('Calling') && call[0]?.includes('with config'),
|
|
);
|
|
expect(debugCall).toBeDefined();
|
|
|
|
const contextStr = JSON.stringify(debugCall?.[1]);
|
|
expect(contextStr).toContain('username=alice');
|
|
expect(contextStr).toContain('password=%5BREDACTED%5D');
|
|
expect(contextStr).toContain('note=hello world');
|
|
expect(contextStr).not.toContain('plain-secret');
|
|
});
|
|
|
|
it('should sanitize newline-terminated form-urlencoded bodies in debug logs', async () => {
|
|
// A trailing newline (e.g. a body loaded from a file) makes the
|
|
// looksLikeUrlEncodedFormData heuristic reject the string, so only the
|
|
// Content-Type-gated branch redacts it. Pin that branch independently.
|
|
const provider = new HttpProvider(testUrl, {
|
|
config: {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: 'username=alice&password=plain-secret\n',
|
|
},
|
|
});
|
|
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
});
|
|
|
|
await provider.callApi('test prompt');
|
|
|
|
const debugCall = loggerDebugSpy.mock.calls.find(
|
|
(call: any) => call[0]?.includes('Calling') && call[0]?.includes('with config'),
|
|
);
|
|
expect(debugCall).toBeDefined();
|
|
const contextStr = JSON.stringify(debugCall?.[1]);
|
|
expect(contextStr).toContain('username=alice');
|
|
expect(contextStr).toContain('password=%5BREDACTED%5D');
|
|
expect(contextStr).not.toContain('plain-secret');
|
|
});
|
|
|
|
describe('Header sanitization in logs', () => {
|
|
it('should sanitize sensitive headers while preserving functionality', async () => {
|
|
const provider = new HttpProvider('https://api.example.com/test', {
|
|
config: {
|
|
method: 'POST',
|
|
body: { message: '{{ prompt }}' },
|
|
headers: {
|
|
Authorization: 'Bearer secret-token-12345',
|
|
'X-API-Key': 'sk-test-abc123',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"success": true}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test message');
|
|
|
|
// Verify the logger was called with a context object
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Calling'),
|
|
expect.objectContaining({ config: expect.any(Object) }),
|
|
);
|
|
|
|
// Extract the context object that was passed to the logger
|
|
const logCall = loggerDebugSpy.mock.calls.find((call: any) => call[0]?.includes('Calling'));
|
|
expect(logCall).toBeDefined();
|
|
const loggedContext = logCall![1];
|
|
|
|
// Verify that when the logger sanitizes this context, sensitive headers are redacted
|
|
const sanitizedContext = sanitizeObject(loggedContext, { context: 'test' });
|
|
const sanitizedHeaders = sanitizedContext.config.headers;
|
|
|
|
// Check for headers with different casing (they may be normalized)
|
|
const authHeader =
|
|
sanitizedHeaders.Authorization ||
|
|
sanitizedHeaders.authorization ||
|
|
Object.entries(sanitizedHeaders).find(
|
|
([key]) => key.toLowerCase() === 'authorization',
|
|
)?.[1];
|
|
const apiKeyHeader =
|
|
sanitizedHeaders['X-API-Key'] ||
|
|
sanitizedHeaders['x-api-key'] ||
|
|
Object.entries(sanitizedHeaders).find(([key]) => key.toLowerCase() === 'x-api-key')?.[1];
|
|
|
|
expect(authHeader).toEqual('[REDACTED]');
|
|
expect(apiKeyHeader).toEqual('[REDACTED]');
|
|
|
|
// Verify actual functionality works - fetchWithCache should get real headers
|
|
expect(fetchWithCache).toHaveBeenCalledWith(
|
|
'https://api.example.com/test',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
authorization: 'Bearer secret-token-12345', // Real token sent (lowercase)
|
|
'x-api-key': 'sk-test-abc123', // Real key sent
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
'text',
|
|
undefined,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should preserve non-sensitive headers in logs', async () => {
|
|
const provider = new HttpProvider('https://api.example.com/test', {
|
|
config: {
|
|
method: 'POST',
|
|
body: { message: '{{ prompt }}' },
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'test-client/1.0',
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"success": true}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test message');
|
|
|
|
// Verify the logger was called
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Calling'),
|
|
expect.objectContaining({ config: expect.any(Object) }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('URL sanitization', () => {
|
|
it('should sanitize URL query parameters', async () => {
|
|
const provider = new HttpProvider(
|
|
'https://api.example.com/test?api_key=secret123&format=json',
|
|
{
|
|
config: {
|
|
method: 'GET',
|
|
},
|
|
},
|
|
);
|
|
|
|
const mockResponse = {
|
|
data: '{"success": true}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test');
|
|
|
|
const debugCall = loggerDebugSpy.mock.calls.find((call: any) => call[0].includes('Calling'));
|
|
expect(debugCall).toBeDefined();
|
|
|
|
const logMessage = debugCall![0];
|
|
expect(logMessage).toContain('api_key=%5BREDACTED%5D');
|
|
expect(logMessage).toContain('format=json'); // Non-sensitive param preserved
|
|
// Note: The URL in config object may contain the original secret, but the main URL is sanitized
|
|
});
|
|
|
|
it('should work with standalone sanitizeUrl function', () => {
|
|
const testCases = [
|
|
{
|
|
input: 'https://user:pass@api.com/test?api_key=secret&normal=value',
|
|
expectContains: ['***:***', 'api_key=%5BREDACTED%5D', 'normal=value'],
|
|
expectNotContains: ['user', 'secret'],
|
|
},
|
|
{
|
|
input: 'https://api.com/test?token=bearer123&id=123',
|
|
expectContains: ['token=%5BREDACTED%5D', 'id=123'],
|
|
expectNotContains: ['bearer123'],
|
|
},
|
|
];
|
|
|
|
testCases.forEach(({ input, expectContains, expectNotContains }) => {
|
|
const result = sanitizeUrl(input);
|
|
|
|
expectContains.forEach((expectedText) => {
|
|
expect(result).toContain(expectedText);
|
|
});
|
|
|
|
expectNotContains.forEach((secretText) => {
|
|
expect(result).not.toContain(secretText);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Combined sanitization scenarios', () => {
|
|
it('should handle both URL and header sanitization together', async () => {
|
|
const provider = new HttpProvider('https://api.example.com/test?api_key=url_secret123', {
|
|
config: {
|
|
method: 'POST',
|
|
body: { data: '{{ prompt }}' },
|
|
headers: {
|
|
Authorization: 'Bearer header_secret456',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"result": "success"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
await provider.callApi('test data');
|
|
|
|
// Verify URL is sanitized in the log message
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('api_key=%5BREDACTED%5D'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('should not impact performance significantly', async () => {
|
|
const provider = new HttpProvider('https://api.example.com/perf', {
|
|
config: {
|
|
method: 'POST',
|
|
body: { test: 'performance' },
|
|
headers: {
|
|
Authorization: 'Bearer perf-token-123',
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"result": "success"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Run multiple calls to test performance impact
|
|
const promises = Array.from({ length: 5 }, () => provider.callApi('performance test'));
|
|
|
|
await Promise.all(promises);
|
|
|
|
const endTime = Date.now();
|
|
const totalTime = endTime - startTime;
|
|
|
|
// Should complete reasonably quickly (less than 500ms for 5 calls)
|
|
expect(totalTime).toBeLessThan(500);
|
|
|
|
// Verify logger was called multiple times
|
|
const debugCalls = loggerDebugSpy.mock.calls.filter(
|
|
(call: any) => call[0]?.includes('Calling') && call[0]?.includes('with config'),
|
|
);
|
|
expect(debugCalls.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
it('should handle empty or undefined values gracefully', async () => {
|
|
const provider = new HttpProvider('https://api.example.com/test', {
|
|
config: {
|
|
method: 'POST',
|
|
body: { test: 'value' },
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: '', // Empty header value
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockResponse = {
|
|
data: '{"result": "test"}',
|
|
status: 200,
|
|
statusText: 'OK',
|
|
cached: false,
|
|
};
|
|
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
|
|
|
|
// Should not crash
|
|
await expect(provider.callApi('test prompt')).resolves.not.toThrow();
|
|
|
|
// Should still log something
|
|
expect(loggerDebugSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle malformed URLs in sanitizeUrl function', () => {
|
|
const malformedInputs = ['not-a-url', '', 'https://[invalid-host]/api'];
|
|
|
|
malformedInputs.forEach((input) => {
|
|
expect(() => sanitizeUrl(input)).not.toThrow();
|
|
const result = sanitizeUrl(input);
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
// Test null/undefined separately as they return the input as-is
|
|
expect(sanitizeUrl(null as any)).toBeNull();
|
|
expect(sanitizeUrl(undefined as any)).toBeUndefined();
|
|
});
|
|
|
|
it('should not leak malformed URL secrets when sanitization fails', () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const result = sanitizeUrl('https://[invalid-host]/api?api_key=plain-secret');
|
|
const warningOutput = warnSpy.mock.calls.flat().join('\n');
|
|
|
|
expect(result).toBe('[REDACTED]');
|
|
expect(warningOutput).not.toContain('plain-secret');
|
|
expect(warningOutput).not.toContain('api_key=');
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
});
|