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
240 lines
7.4 KiB
TypeScript
240 lines
7.4 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import http from 'http';
|
|
import { AddressInfo } from 'net';
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { evaluate } from '../../src/evaluator';
|
|
import { runDbMigrations } from '../../src/migrate';
|
|
import Eval from '../../src/models/eval';
|
|
import { findTargetErrorStatus, isNonTransientHttpStatus } from '../../src/util/fetch/errors';
|
|
import { createMockProvider } from '../factories/provider';
|
|
|
|
import type { ApiProvider, Prompt, TestSuite } from '../../src/types';
|
|
|
|
function toPrompt(text: string): Prompt {
|
|
return { raw: text, label: text };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await runDbMigrations();
|
|
});
|
|
|
|
describe('abort on target error', () => {
|
|
let server: http.Server;
|
|
let serverPort: number;
|
|
let serverUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
// Create a mock server that returns 403 Forbidden
|
|
server = http.createServer((_req, res) => {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
});
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, () => {
|
|
const address = server.address() as AddressInfo;
|
|
serverPort = address.port;
|
|
serverUrl = `http://localhost:${serverPort}`;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
});
|
|
});
|
|
|
|
it('should abort scan when target returns 403', async () => {
|
|
const startTime = Date.now();
|
|
|
|
// Create a mock provider that returns 403
|
|
const mockApiProvider = createMockProvider({
|
|
id: 'test-http-provider',
|
|
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation(async () => {
|
|
// Simulate HTTP call to our mock server
|
|
const response = await fetch(serverUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt: 'test' }),
|
|
});
|
|
return {
|
|
output: await response.text(),
|
|
metadata: {
|
|
http: {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
},
|
|
},
|
|
};
|
|
}),
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt {{ name }}')],
|
|
tests: [
|
|
{ vars: { name: 'test1' } },
|
|
{ vars: { name: 'test2' } },
|
|
{ vars: { name: 'test3' } },
|
|
{ vars: { name: 'test4' } },
|
|
{ vars: { name: 'test5' } },
|
|
],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
|
|
// Run evaluation
|
|
await evaluate(testSuite, evalRecord, {
|
|
maxConcurrency: 1, // Run sequentially to ensure abort logic triggers
|
|
});
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Verify scan aborted quickly (should not take 9+ hours!)
|
|
// With 5 tests and maxConcurrency 1, if it didn't abort it would process all 5
|
|
expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds
|
|
|
|
// Verify results contain the 403 error
|
|
const results = await evalRecord.getResults();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
|
|
// Find the target error status
|
|
const targetErrorStatus = findTargetErrorStatus(results);
|
|
expect(targetErrorStatus).toBe(403);
|
|
|
|
// Verify 403 is detected as non-transient
|
|
expect(isNonTransientHttpStatus(403)).toBe(true);
|
|
});
|
|
|
|
it('should include HTTP status in result metadata', async () => {
|
|
// Create a mock provider that returns 403
|
|
const mockApiProvider = createMockProvider({
|
|
id: 'test-http-provider',
|
|
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation(async () => {
|
|
const response = await fetch(serverUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt: 'test' }),
|
|
});
|
|
return {
|
|
output: await response.text(),
|
|
metadata: {
|
|
http: {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
},
|
|
},
|
|
};
|
|
}),
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt')],
|
|
tests: [{ vars: {} }],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
|
|
await evaluate(testSuite, evalRecord, {
|
|
maxConcurrency: 1,
|
|
});
|
|
|
|
const results = await evalRecord.getResults();
|
|
expect(results.length).toBe(1);
|
|
|
|
// Verify HTTP status is preserved in result metadata
|
|
const result = results[0];
|
|
expect(result.response?.metadata?.http?.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('non-transient HTTP status detection', () => {
|
|
it('should correctly identify non-transient status codes', () => {
|
|
// Non-transient (should abort)
|
|
expect(isNonTransientHttpStatus(401)).toBe(true);
|
|
expect(isNonTransientHttpStatus(403)).toBe(true);
|
|
expect(isNonTransientHttpStatus(404)).toBe(true);
|
|
expect(isNonTransientHttpStatus(501)).toBe(true);
|
|
|
|
// Transient (should NOT abort - may recover on retry)
|
|
expect(isNonTransientHttpStatus(500)).toBe(false); // Internal server error (often transient)
|
|
expect(isNonTransientHttpStatus(429)).toBe(false); // Rate limit
|
|
expect(isNonTransientHttpStatus(502)).toBe(false); // Bad gateway
|
|
expect(isNonTransientHttpStatus(503)).toBe(false); // Service unavailable
|
|
expect(isNonTransientHttpStatus(504)).toBe(false); // Gateway timeout
|
|
|
|
// Success codes (should NOT abort)
|
|
expect(isNonTransientHttpStatus(200)).toBe(false);
|
|
expect(isNonTransientHttpStatus(201)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Eval.findTargetErrorStatus() - efficient DB query', () => {
|
|
it('should find target error via database query without loading all results', async () => {
|
|
// Create a mock provider that returns 403
|
|
const mockApiProvider = createMockProvider({
|
|
id: 'test-http-provider-db',
|
|
response: {
|
|
output: 'Forbidden',
|
|
metadata: {
|
|
http: {
|
|
status: 403,
|
|
statusText: 'Forbidden',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt')],
|
|
tests: [{ vars: {} }],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
|
|
await evaluate(testSuite, evalRecord, {
|
|
maxConcurrency: 1,
|
|
});
|
|
|
|
// This should do an efficient DB query, not load all results
|
|
const targetErrorStatus = await evalRecord.findTargetErrorStatus();
|
|
expect(targetErrorStatus).toBe(403);
|
|
});
|
|
|
|
it('should return undefined when no target error exists', async () => {
|
|
// Create a mock provider that returns 200
|
|
const mockApiProvider = createMockProvider({
|
|
id: 'test-http-provider-ok',
|
|
response: {
|
|
output: 'Success',
|
|
metadata: {
|
|
http: {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const testSuite: TestSuite = {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt')],
|
|
tests: [{ vars: {} }],
|
|
};
|
|
|
|
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
|
|
|
|
await evaluate(testSuite, evalRecord, {
|
|
maxConcurrency: 1,
|
|
});
|
|
|
|
const targetErrorStatus = await evalRecord.findTargetErrorStatus();
|
|
expect(targetErrorStatus).toBeUndefined();
|
|
});
|
|
});
|