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

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();
});
});