chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { AddressInfo } from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { HttpProvider } from '../../src/providers/http';
|
||||
|
||||
interface MockFileSummary {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
interface MockRequestSummary {
|
||||
apiKey: string | null;
|
||||
contentType: string | null;
|
||||
documentQuery: string | null;
|
||||
files: MockFileSummary[];
|
||||
method: string | undefined;
|
||||
url: string | undefined;
|
||||
}
|
||||
|
||||
const servers: Server[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createMultipartDocumentSummarizerServer() {
|
||||
let lastRequest: MockRequestSummary | undefined;
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'POST' || req.url !== '/api/genai/analyze-file') {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey !== 'test-api-key') {
|
||||
res.writeHead(401, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'invalid api key' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
const contentType = String(req.headers['content-type'] || '');
|
||||
const request = new Request(`http://localhost${req.url}`, {
|
||||
body,
|
||||
headers: { 'content-type': contentType },
|
||||
method: req.method,
|
||||
});
|
||||
const formData = await request.formData();
|
||||
const documentQuery = formData.get('documentQuery');
|
||||
const files = formData.getAll('files');
|
||||
|
||||
if (typeof documentQuery !== 'string' || documentQuery.trim() === '') {
|
||||
res.writeHead(400, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'missing documentQuery' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0 || !files.every((file) => file instanceof File)) {
|
||||
res.writeHead(400, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'missing files' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const summaries: MockFileSummary[] = [];
|
||||
for (const file of files as File[]) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
summaries.push({
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
sizeBytes: buffer.length,
|
||||
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
||||
prefix: buffer.subarray(0, 8).toString('utf8'),
|
||||
});
|
||||
}
|
||||
|
||||
lastRequest = {
|
||||
apiKey: String(apiKey),
|
||||
contentType,
|
||||
documentQuery,
|
||||
files: summaries,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: `mock summary for ${summaries.length} file(s): ${documentQuery}`,
|
||||
fileCount: summaries.length,
|
||||
files: summaries,
|
||||
documentQuery,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
res.writeHead(500, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'mock server failed to parse multipart request' }));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/api/genai/analyze-file`,
|
||||
getLastRequest: () => lastRequest,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('HttpProvider structured multipart requests', () => {
|
||||
it('executes the generated-document config emitted by the Cloud HTTP editor defaults', async () => {
|
||||
const mockServer = await createMultipartDocumentSummarizerServer();
|
||||
const provider = new HttpProvider('http', {
|
||||
config: {
|
||||
url: mockServer.url,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': 'test-api-key',
|
||||
},
|
||||
multipart: {
|
||||
parts: [
|
||||
{
|
||||
kind: 'file',
|
||||
name: 'files',
|
||||
filename: 'promptfoo-document.pdf',
|
||||
source: {
|
||||
type: 'generated',
|
||||
generator: 'basic-document',
|
||||
format: 'pdf',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'documentQuery',
|
||||
value: '{{prompt}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformResponse: 'json.summary',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Summarize this upload');
|
||||
|
||||
expect(result.output).toBe('mock summary for 1 file(s): Summarize this upload');
|
||||
expect(mockServer.getLastRequest()).toMatchObject({
|
||||
documentQuery: 'Summarize this upload',
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
filename: 'promptfoo-document.pdf',
|
||||
contentType: 'application/pdf',
|
||||
prefix: '%PDF-1.4',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders path sources with per-test variables before reading local files', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-multipart-'));
|
||||
tempDirs.push(tempDir);
|
||||
const reportPath = path.join(tempDir, 'report-a.txt');
|
||||
const standardFileUrl = new URL(`file://${reportPath}`).toString();
|
||||
fs.writeFileSync(reportPath, 'report-a contents');
|
||||
|
||||
const mockServer = await createMultipartDocumentSummarizerServer();
|
||||
const provider = new HttpProvider('http', {
|
||||
config: {
|
||||
url: mockServer.url,
|
||||
headers: { 'X-API-Key': 'test-api-key' },
|
||||
multipart: {
|
||||
parts: [
|
||||
{
|
||||
kind: 'file',
|
||||
name: 'files',
|
||||
source: {
|
||||
type: 'path',
|
||||
path: 'file://{{documentPath}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'documentQuery',
|
||||
value: '{{prompt}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformResponse: 'json.summary',
|
||||
},
|
||||
});
|
||||
|
||||
await provider.callApi('Summarize local fixture', {
|
||||
prompt: { raw: 'Summarize local fixture', label: 'query' },
|
||||
vars: {
|
||||
documentPath: reportPath,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockServer.getLastRequest()).toMatchObject({
|
||||
documentQuery: 'Summarize local fixture',
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
filename: 'report-a.txt',
|
||||
contentType: 'text/plain',
|
||||
sizeBytes: Buffer.byteLength('report-a contents'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await provider.callApi('Summarize local fixture', {
|
||||
prompt: { raw: 'Summarize local fixture', label: 'query' },
|
||||
vars: {
|
||||
documentPath: standardFileUrl.slice('file://'.length),
|
||||
},
|
||||
});
|
||||
expect(mockServer.getLastRequest()?.files[0]).toMatchObject({
|
||||
filename: 'report-a.txt',
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts secret-like multipart text fields from debug metadata', async () => {
|
||||
const mockServer = await createMultipartDocumentSummarizerServer();
|
||||
const provider = new HttpProvider('http', {
|
||||
config: {
|
||||
url: mockServer.url,
|
||||
headers: { 'X-API-Key': 'test-api-key' },
|
||||
multipart: {
|
||||
parts: [
|
||||
{
|
||||
kind: 'file',
|
||||
name: 'files',
|
||||
source: { type: 'generated', format: 'pdf' },
|
||||
},
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'documentQuery',
|
||||
value: '{{prompt}}',
|
||||
},
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'api_key',
|
||||
value: 'sk-secret-key-that-should-not-appear-anywhere',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformResponse: 'json.summary',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Query', {
|
||||
debug: true,
|
||||
prompt: { raw: 'Query', label: 'query' },
|
||||
vars: {},
|
||||
});
|
||||
|
||||
expect(result.metadata?.multipart.fields).toContainEqual({
|
||||
field: 'api_key',
|
||||
value: '[REDACTED]',
|
||||
});
|
||||
expect(result.metadata?.multipart.files[0]).not.toHaveProperty('sha256');
|
||||
expect(JSON.stringify(result.metadata)).not.toContain('sk-secret-key');
|
||||
});
|
||||
|
||||
it('redacts secret-like multipart filenames from debug metadata', async () => {
|
||||
const mockServer = await createMultipartDocumentSummarizerServer();
|
||||
const provider = new HttpProvider('http', {
|
||||
config: {
|
||||
url: mockServer.url,
|
||||
headers: { 'X-API-Key': 'test-api-key' },
|
||||
multipart: {
|
||||
parts: [
|
||||
{
|
||||
kind: 'file',
|
||||
name: 'files',
|
||||
filename: 'sk-secret-filename-that-should-not-appear',
|
||||
source: { type: 'generated', format: 'pdf' },
|
||||
},
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'documentQuery',
|
||||
value: '{{prompt}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformResponse: 'json.summary',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Query', {
|
||||
debug: true,
|
||||
prompt: { raw: 'Query', label: 'query' },
|
||||
vars: {},
|
||||
});
|
||||
|
||||
expect(result.metadata?.multipart.files[0]).toMatchObject({
|
||||
field: 'files',
|
||||
filename: '[REDACTED]',
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
expect(result.metadata?.multipart.files[0]).not.toHaveProperty('sha256');
|
||||
expect(JSON.stringify(result.metadata)).not.toContain('sk-secret-filename');
|
||||
});
|
||||
|
||||
it('rejects incompatible multipart HTTP provider modes before sending requests', () => {
|
||||
expect(
|
||||
() =>
|
||||
new HttpProvider('http', {
|
||||
config: {
|
||||
request: 'POST / HTTP/1.1\nHost: example.com\n\n{{prompt}}',
|
||||
multipart: { parts: [{ kind: 'field', name: 'documentQuery', value: '{{prompt}}' }] },
|
||||
},
|
||||
}),
|
||||
).toThrow(/cannot include both request and multipart/);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new HttpProvider('http', {
|
||||
config: {
|
||||
body: { prompt: '{{prompt}}' },
|
||||
multipart: { parts: [{ kind: 'field', name: 'documentQuery', value: '{{prompt}}' }] },
|
||||
},
|
||||
}),
|
||||
).toThrow(/cannot include both body and multipart/);
|
||||
});
|
||||
|
||||
it('rejects multipart GET requests before rendering the multipart body', async () => {
|
||||
const provider = new HttpProvider('http://example.com/upload', {
|
||||
config: {
|
||||
method: 'GET',
|
||||
multipart: {
|
||||
parts: [
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'documentQuery',
|
||||
value: '{{prompt}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.callApi('Query')).rejects.toThrow(/GET requests cannot use multipart/);
|
||||
});
|
||||
|
||||
it('uploads a generated PDF file with a templated document query', async () => {
|
||||
const mockServer = await createMultipartDocumentSummarizerServer();
|
||||
const provider = new HttpProvider('http', {
|
||||
config: {
|
||||
url: mockServer.url,
|
||||
headers: {
|
||||
'X-API-Key': 'test-api-key',
|
||||
// The provider must remove this stale value and let fetch set a boundary.
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
multipart: {
|
||||
parts: [
|
||||
{
|
||||
kind: 'file',
|
||||
name: 'files',
|
||||
filename: 'sample-report45.pdf',
|
||||
source: {
|
||||
type: 'generated',
|
||||
format: 'pdf',
|
||||
text: 'Benign generated report used to test multipart transport.',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'documentQuery',
|
||||
value: '{{prompt}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformResponse: 'json.summary',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Give summary of the file', {
|
||||
debug: true,
|
||||
prompt: { raw: 'Give summary of the file', label: 'query' },
|
||||
vars: {},
|
||||
});
|
||||
|
||||
expect(result.output).toBe('mock summary for 1 file(s): Give summary of the file');
|
||||
|
||||
const request = mockServer.getLastRequest();
|
||||
expect(request).toMatchObject({
|
||||
apiKey: 'test-api-key',
|
||||
documentQuery: 'Give summary of the file',
|
||||
method: 'POST',
|
||||
url: '/api/genai/analyze-file',
|
||||
});
|
||||
expect(request?.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
||||
expect(request?.files).toHaveLength(1);
|
||||
expect(request?.files[0]).toMatchObject({
|
||||
filename: 'sample-report45.pdf',
|
||||
contentType: 'application/pdf',
|
||||
prefix: '%PDF-1.4',
|
||||
});
|
||||
expect(request?.files[0].sizeBytes).toBeGreaterThan(100);
|
||||
|
||||
expect(result.metadata?.multipart).toMatchObject({
|
||||
fields: [{ field: 'documentQuery', value: 'Give summary of the file' }],
|
||||
files: [
|
||||
{
|
||||
field: 'files',
|
||||
filename: 'sample-report45.pdf',
|
||||
contentType: 'application/pdf',
|
||||
source: 'generated',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.metadata?.multipart.files[0]).not.toHaveProperty('sha256');
|
||||
expect(JSON.stringify(result.metadata)).not.toContain('Benign generated report');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user