chore: import upstream snapshot with attribution
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { runDbMigrations } from '../../src/migrate';
import Eval from '../../src/models/eval';
import EvalResult from '../../src/models/evalResult';
import { createApp } from '../../src/server/server';
import { STRIPPED_TABLE_CELL_PROMPT } from '../../src/util/eval/evalTableUtils';
import invariant from '../../src/util/invariant';
import EvalFactory from '../factories/evalFactory';
vi.mock('../../src/database/signal', async () => {
const actual = await vi.importActual('../../src/database/signal');
return {
...actual,
updateSignalFile: vi.fn(),
};
});
describe('eval routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
const testEvalIds = new Set<string>();
beforeAll(async () => {
await runDbMigrations();
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
afterEach(async () => {
vi.restoreAllMocks();
// More robust cleanup with proper error handling
const cleanupPromises = Array.from(testEvalIds).map(async (evalId) => {
try {
const eval_ = await Eval.findById(evalId);
if (eval_) {
await eval_.delete();
}
} catch (error) {
// Log the error instead of silently ignoring it
console.error(`Failed to cleanup eval ${evalId}:`, error);
}
});
// Wait for all cleanups to complete
await Promise.allSettled(cleanupPromises);
testEvalIds.clear();
vi.resetAllMocks();
});
function mockTablePayloadRangeError(shouldThrow: (attempt: number) => boolean) {
const originalStringify = JSON.stringify;
let tablePayloadAttempts = 0;
return vi
.spyOn(JSON, 'stringify')
.mockImplementation((...args: Parameters<typeof JSON.stringify>) => {
const value = args[0];
if (value && typeof value === 'object' && 'table' in value && 'totalCount' in value) {
tablePayloadAttempts += 1;
if (shouldThrow(tablePayloadAttempts)) {
throw new RangeError('Invalid string length');
}
}
return originalStringify.apply(JSON, args);
});
}
async function setResultPromptRaws(eval_: Eval, raws: string[]) {
const results = await eval_.getResults();
await Promise.all(
raws.map(async (raw, index) => {
const result = results[index];
invariant(result instanceof EvalResult, 'EvalResult is required');
result.prompt = { ...result.prompt, raw };
await result.save();
}),
);
}
function createManualRatingPayload(originalResult: any, pass: boolean) {
const payload = { ...originalResult.gradingResult };
const score = pass ? 1 : 0;
payload.componentResults?.push({
pass,
score,
reason: 'Manual result (overrides all other grading results)',
assertion: { type: 'human' },
});
payload.reason = 'Manual result (overrides all other grading results)';
payload.pass = pass;
payload.score = score;
return payload;
}
describe('POST /', () => {
it('returns 500 when v4 prompt persistence fails', async () => {
const createSpy = vi.spyOn(Eval, 'create');
vi.spyOn(Eval.prototype, 'addPrompts').mockRejectedValueOnce(
new Error('prompt persistence failed'),
);
const res = await api.post('/api/eval').send({
config: {
description: 'v4 save test',
tests: [],
},
prompts: [{ raw: 'hello', label: 'hello' }],
results: [],
});
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Failed to write eval to database' });
const createdEval = await createSpy.mock.results[0]?.value;
if (createdEval) {
testEvalIds.add(createdEval.id);
}
});
});
describe('post("/:evalId/results/:id/rating")', () => {
it('rejects result ratings when the URL eval does not own the result', async () => {
const evalA = await EvalFactory.create();
const evalB = await EvalFactory.create();
testEvalIds.add(evalA.id);
testEvalIds.add(evalB.id);
const resultsB = await evalB.getResults();
const resultB = resultsB[0];
invariant(resultB.id, 'Result ID is required');
const originalResultB = {
gradingResult: structuredClone(resultB.gradingResult),
score: resultB.score,
success: resultB.success,
};
const originalEvalAMetrics = structuredClone(evalA.prompts[resultB.promptIdx].metrics);
const originalEvalBMetrics = structuredClone(evalB.prompts[resultB.promptIdx].metrics);
const res = await api
.post(`/api/eval/${evalA.id}/results/${resultB.id}/rating`)
.send(createManualRatingPayload(resultB, false));
expect(res.status).toBe(404);
expect(res.body.error).toBe('Result not found');
const updatedResultB = await EvalResult.findById(resultB.id);
expect(updatedResultB?.gradingResult).toEqual(originalResultB.gradingResult);
expect(updatedResultB?.score).toBe(originalResultB.score);
expect(updatedResultB?.success).toBe(originalResultB.success);
const updatedEvalA = await Eval.findById(evalA.id);
const updatedEvalB = await Eval.findById(evalB.id);
invariant(updatedEvalA, 'Eval A is required');
invariant(updatedEvalB, 'Eval B is required');
expect(updatedEvalA.prompts[resultB.promptIdx].metrics).toEqual(originalEvalAMetrics);
expect(updatedEvalB.prompts[resultB.promptIdx].metrics).toEqual(originalEvalBMetrics);
});
it('returns a JSON 500 response when rating storage fails', async () => {
const findByIdSpy = vi
.spyOn(EvalResult, 'findById')
.mockRejectedValueOnce(new Error('database unavailable'));
const res = await api
.post('/api/eval/eval-1/results/result-1/rating')
.send({ pass: true, score: 1 });
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Failed to submit rating' });
expect(findByIdSpy).toHaveBeenCalledWith('result-1');
});
it('returns the persisted result row so SDK clients see refreshed metrics', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const results = await eval_.getResults();
// Pick the failing result so flipping `pass=true` produces a real
// state transition. With `results[0]` (already passing), every numeric
// field would be unchanged and the test couldn't distinguish a fresh
// post-rating row from a pre-rating snapshot.
const result = results[1];
invariant(result.id, 'Result ID is required');
expect(result.gradingResult?.pass).toBe(false);
expect(result.score).toBe(0);
expect(result.success).toBe(false);
const res = await api
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
.send(createManualRatingPayload(result, true));
expect(res.status).toBe(200);
// The body must reflect the *post-rating* state, not just include the
// row's id — external SDK consumers depend on reading refreshed
// grading state without a follow-up GET. If the route ever regressed
// to returning a pre-save snapshot or a generic `{message}` envelope,
// the strict equality on flipped fields below would catch it.
expect(res.body.id).toBe(result.id);
expect(res.body.success).toBe(true);
expect(res.body.score).toBe(1);
expect(res.body.gradingResult?.pass).toBe(true);
expect(res.body.gradingResult?.score).toBe(1);
expect(res.body.gradingResult?.reason).toContain('Manual result');
});
it('persists the rated result before notifying through the eval save', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const results = await eval_.getResults();
const result = results[1];
invariant(result.id, 'Result ID is required');
const resultSaveSpy = vi.spyOn(EvalResult.prototype, 'save');
const evalSaveSpy = vi.spyOn(Eval.prototype, 'save');
const res = await api
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
.send(createManualRatingPayload(result, true));
expect(res.status).toBe(200);
expect(resultSaveSpy).toHaveBeenCalledTimes(1);
expect(evalSaveSpy).toHaveBeenCalledTimes(1);
expect(resultSaveSpy.mock.invocationCallOrder[0]).toBeLessThan(
evalSaveSpy.mock.invocationCallOrder[0],
);
});
it('Passing test and the user marked it as passing (no change)', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const results = await eval_.getResults();
const result = results[0];
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
expect(result.gradingResult?.pass).toBe(true);
expect(result.gradingResult?.score).toBe(1);
const ratingPayload = createManualRatingPayload(result, true);
const res = await api
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
.send(ratingPayload);
expect(res.status).toBe(200);
invariant(result.id, 'Result ID is required');
const updatedResult = await EvalResult.findById(result.id);
expect(updatedResult?.gradingResult?.pass).toBe(true);
expect(updatedResult?.gradingResult?.score).toBe(1);
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
expect(updatedResult?.gradingResult?.reason).toBe(
'Manual result (overrides all other grading results)',
);
const updatedEval = await Eval.findById(eval_.id);
invariant(updatedEval, 'Eval is required');
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(2);
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
});
it('Passing test and the user changed it to failing', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const results = await eval_.getResults();
const result = results[0];
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
expect(result.gradingResult?.pass).toBe(true);
expect(result.gradingResult?.score).toBe(1);
const ratingPayload = createManualRatingPayload(result, false);
const res = await api
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
.send(ratingPayload);
expect(res.status).toBe(200);
invariant(result.id, 'Result ID is required');
const updatedResult = await EvalResult.findById(result.id);
expect(updatedResult?.gradingResult?.pass).toBe(false);
expect(updatedResult?.gradingResult?.score).toBe(0);
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
expect(updatedResult?.gradingResult?.reason).toBe(
'Manual result (overrides all other grading results)',
);
const updatedEval = await Eval.findById(eval_.id);
invariant(updatedEval, 'Eval is required');
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(2);
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(0);
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(0);
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(2);
});
it('Failing test and the user changed it to passing', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const results = await eval_.getResults();
const result = results[1];
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
expect(result.gradingResult?.pass).toBe(false);
expect(result.gradingResult?.score).toBe(0);
const ratingPayload = createManualRatingPayload(result, true);
const res = await api
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
.send(ratingPayload);
expect(res.status).toBe(200);
invariant(result.id, 'Result ID is required');
const updatedResult = await EvalResult.findById(result.id);
expect(updatedResult?.gradingResult?.pass).toBe(true);
expect(updatedResult?.gradingResult?.score).toBe(1);
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
expect(updatedResult?.gradingResult?.reason).toBe(
'Manual result (overrides all other grading results)',
);
const updatedEval = await Eval.findById(eval_.id);
invariant(updatedEval, 'Eval is required');
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(2);
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(2);
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(2);
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(0);
});
it('Failing test and the user marked it as failing (no change)', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const results = await eval_.getResults();
const result = results[1];
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
expect(result.gradingResult?.pass).toBe(false);
expect(result.gradingResult?.score).toBe(0);
const ratingPayload = createManualRatingPayload(result, false);
const res = await api
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
.send(ratingPayload);
expect(res.status).toBe(200);
invariant(result.id, 'Result ID is required');
const updatedResult = await EvalResult.findById(result.id);
expect(updatedResult?.gradingResult?.pass).toBe(false);
expect(updatedResult?.gradingResult?.score).toBe(0);
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
expect(updatedResult?.gradingResult?.reason).toBe(
'Manual result (overrides all other grading results)',
);
const updatedEval = await Eval.findById(eval_.id);
invariant(updatedEval, 'Eval is required');
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(2);
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
});
});
describe('GET /:id/table - large payload handling', () => {
it('preserves config tests returned from the table endpoint when saved back', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const res = await api.get(`/api/eval/${eval_.id}/table`);
expect(res.status).toBe(200);
expect(res.body.config.tests).toHaveLength(2);
const patchRes = await api
.patch(`/api/eval/${eval_.id}`)
.send({ config: { ...res.body.config, description: 'renamed eval' } });
expect(patchRes.status).toBe(200);
const updatedEval = await Eval.findById(eval_.id);
invariant(updatedEval, 'Eval is required');
expect(updatedEval.config.tests).toHaveLength(2);
});
it('preserves Azure Blob SAS tokens when a redacted table config is saved back', async () => {
const sasUri =
'az://{{ account }}/container/{{ suite }}.yaml?sp=r&sig=azure-secret&sv={{ version }}';
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
eval_.config.tests = sasUri;
await eval_.save();
const res = await api.get(`/api/eval/${eval_.id}/table`);
expect(res.status).toBe(200);
expect(res.body.config.tests).toBe(
'az://{{ account }}/container/{{ suite }}.yaml?sp=r&sig=%5BREDACTED%5D&sv={{ version }}',
);
const patchRes = await api
.patch(`/api/eval/${eval_.id}`)
.send({ config: { ...res.body.config, description: 'renamed eval' } });
expect(patchRes.status).toBe(200);
const updatedEval = await Eval.findById(eval_.id);
invariant(updatedEval, 'Eval is required');
expect(updatedEval.config.tests).toBe(sasUri);
expect(updatedEval.config.description).toBe('renamed eval');
});
it('returns table data with only the largest per-cell prompt stripped when possible', async () => {
const eval_ = await EvalFactory.create({ numResults: 3 });
testEvalIds.add(eval_.id);
await setResultPromptRaws(eval_, ['small prompt', 'x'.repeat(100), 'x'.repeat(50)]);
mockTablePayloadRangeError((attempt) => attempt === 1);
const res = await api.get(`/api/eval/${eval_.id}/table`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('table');
expect(res.body.table.body.length).toBeGreaterThan(0);
expect(res.body.config.tests).toHaveLength(2);
const prompts: Array<string | undefined> = res.body.table.body.flatMap(
(row: { outputs: Array<{ prompt?: string }> }) =>
row.outputs.map((output) => output?.prompt),
);
expect(prompts.filter((prompt) => prompt === STRIPPED_TABLE_CELL_PROMPT)).toHaveLength(1);
expect(prompts).toContain('small prompt');
expect(prompts).toContain('x'.repeat(50));
});
it('strips per-cell prompts largest first until the response serializes', async () => {
const eval_ = await EvalFactory.create({ numResults: 3 });
testEvalIds.add(eval_.id);
await setResultPromptRaws(eval_, ['small prompt', 'x'.repeat(100), 'x'.repeat(50)]);
mockTablePayloadRangeError((attempt) => attempt <= 2);
const res = await api.get(`/api/eval/${eval_.id}/table`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('table');
expect(res.body.config.tests).toHaveLength(2);
const prompts: Array<string | undefined> = res.body.table.body.flatMap(
(row: { outputs: Array<{ prompt?: string }> }) =>
row.outputs.map((output) => output?.prompt),
);
expect(prompts.filter((prompt) => prompt === STRIPPED_TABLE_CELL_PROMPT)).toHaveLength(2);
expect(prompts).toContain('small prompt');
});
it('returns 413 when the table response is still too large after stripping prompts', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
mockTablePayloadRangeError(() => true);
const res = await api.get(`/api/eval/${eval_.id}/table`);
expect(res.status).toBe(413);
expect(res.body).toEqual({
error: 'Eval too large to display. Try reducing the page size.',
});
});
});
describe('GET /:id/metadata-keys', () => {
it('should return metadata keys for valid eval', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
// Add eval results with metadata using direct database insert
const { getDb } = await import('../../src/database');
const db = await getDb();
await db.run(
`INSERT INTO eval_results (
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
success, score, metadata
) VALUES
('result1', '${eval_.id}', 0, 0, '{}', '{}', '{}', 1, 1.0, '{"key1": "value1", "key2": "value2"}'),
('result2', '${eval_.id}', 0, 1, '{}', '{}', '{}', 1, 1.0, '{"key2": "value3", "key3": "value4"}')`,
);
const res = await api.get(`/api/eval/${eval_.id}/metadata-keys`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('keys');
expect(res.body.keys).toEqual(expect.arrayContaining(['key1', 'key2', 'key3']));
});
it('should return 404 for non-existent eval', async () => {
const res = await api.get('/api/eval/non-existent-id/metadata-keys');
expect(res.status).toBe(404);
expect(res.body).toHaveProperty('error', 'Eval not found');
});
it('should return empty keys array for eval with no metadata', async () => {
const eval_ = await EvalFactory.create();
testEvalIds.add(eval_.id);
const res = await api.get(`/api/eval/${eval_.id}/metadata-keys`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('keys');
expect(res.body.keys).toEqual([]);
});
});
});
+142
View File
@@ -0,0 +1,142 @@
import path from 'node:path';
import type { PathLike } from 'node:fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock fs before importing the module - spread original to keep other functions working
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
default: {
...actual,
existsSync: vi.fn(),
},
};
});
vi.mock('../../src/esm', () => ({
getDirectory: vi.fn(),
}));
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock other server dependencies to prevent initialization errors
vi.mock('../../src/migrate', () => ({
runDbMigrations: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/database/signal', () => ({
setupSignalWatcher: vi.fn().mockReturnValue({ close: vi.fn(), on: vi.fn() }),
readSignalFile: vi.fn().mockReturnValue({ type: 'update' }),
}));
vi.mock('../../src/util/server', () => ({
BrowserBehavior: { OPEN: 0, SKIP: 1, ASK: 2 },
BrowserBehaviorNames: { 0: 'OPEN', 1: 'SKIP', 2: 'ASK' },
openBrowser: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/models/eval', () => ({
default: { latest: vi.fn().mockResolvedValue(null) },
getEvalSummaries: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/globalConfig/cloud', () => ({
cloudConfig: { isEnabled: vi.fn().mockReturnValue(false) },
}));
import fs from 'node:fs';
import { getDirectory } from '../../src/esm';
import logger from '../../src/logger';
import { findStaticDir } from '../../src/server/server';
describe('findStaticDir', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns standard path when index.html exists there (development)', () => {
// Simulate development: getDirectory returns 'src/', app is at 'src/app/'
vi.mocked(getDirectory).mockReturnValue('/project/src');
vi.mocked(fs.existsSync).mockImplementation((filePath: PathLike) => {
// Standard path check: /project/src/app/index.html
return String(filePath) === path.join('/project/src', 'app', 'index.html');
});
const result = findStaticDir();
expect(result).toBe(path.join('/project/src', 'app'));
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns parent path when bundled (standard path missing, parent exists)', () => {
// Simulate bundled: getDirectory returns 'dist/src/server/', app is at 'dist/src/app/'
vi.mocked(getDirectory).mockReturnValue('/project/dist/src/server');
vi.mocked(fs.existsSync).mockImplementation((filePath: PathLike) => {
const pathStr = String(filePath);
// Standard path doesn't exist
if (pathStr === path.join('/project/dist/src/server', 'app', 'index.html')) {
return false;
}
// Parent path exists: /project/dist/src/app/index.html
if (pathStr === path.resolve('/project/dist/src/server', '..', 'app', 'index.html')) {
return true;
}
return false;
});
const result = findStaticDir();
expect(result).toBe(path.resolve('/project/dist/src/server', '..', 'app'));
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Static directory resolved to parent'),
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to standard path with warning when neither path exists', () => {
vi.mocked(getDirectory).mockReturnValue('/project/dist/src/server');
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = findStaticDir();
// Should return standard path as fallback
expect(result).toBe(path.join('/project/dist/src/server', 'app'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Static directory not found'));
});
it('checks index.html in both paths before falling back', () => {
vi.mocked(getDirectory).mockReturnValue('/test/dir');
vi.mocked(fs.existsSync).mockReturnValue(false);
findStaticDir();
// Should have checked both paths
expect(fs.existsSync).toHaveBeenCalledWith(path.join('/test/dir', 'app', 'index.html'));
expect(fs.existsSync).toHaveBeenCalledWith(
path.resolve('/test/dir', '..', 'app', 'index.html'),
);
});
it('prefers standard path over parent path when both exist', () => {
vi.mocked(getDirectory).mockReturnValue('/project/src');
// Both paths have index.html
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = findStaticDir();
// Should return standard path (first check wins)
expect(result).toBe(path.join('/project/src', 'app'));
expect(logger.debug).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,230 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { csrfProtection } from '../../../src/server/middleware/csrfProtection';
import type { NextFunction, Request, Response } from 'express';
vi.mock('../../../src/logger', () => ({
default: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
}));
vi.mock('../../../src/envars', () => ({
getEnvString: vi.fn((_key: string, defaultValue?: string) => defaultValue ?? ''),
}));
import { getEnvString } from '../../../src/envars';
function mockReq(overrides: Partial<Request> = {}): Request {
return {
method: 'POST',
path: '/api/eval',
headers: {},
...overrides,
} as unknown as Request;
}
function mockRes(): Response {
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response;
return res;
}
describe('csrfProtection', () => {
let next: NextFunction;
beforeEach(() => {
next = vi.fn();
vi.mocked(getEnvString).mockImplementation(
(_key: string, defaultValue?: string) => defaultValue ?? '',
);
});
afterEach(() => {
vi.resetAllMocks();
});
// Safe methods always pass
describe('safe methods', () => {
it.each(['GET', 'HEAD', 'OPTIONS'])('%s requests pass through', (method) => {
const req = mockReq({
method,
headers: { 'sec-fetch-site': 'cross-site', origin: 'http://evil.com' },
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
// Path 1: Sec-Fetch-Site present
describe('with Sec-Fetch-Site header', () => {
it.each(['same-origin', 'same-site', 'none'])('allows POST with sec-fetch-site=%s', (value) => {
const req = mockReq({ headers: { 'sec-fetch-site': value, host: 'localhost:15500' } });
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
it('blocks cross-site POST from evil.com', () => {
const req = mockReq({
headers: {
'sec-fetch-site': 'cross-site',
origin: 'http://evil.com',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('allows cross-site from local.promptfoo.app to localhost (localhost equiv)', () => {
const req = mockReq({
headers: {
'sec-fetch-site': 'cross-site',
origin: 'http://local.promptfoo.app:5173',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
it('allows cross-site from 127.0.0.1 to localhost (localhost equiv)', () => {
const req = mockReq({
headers: {
'sec-fetch-site': 'cross-site',
origin: 'http://127.0.0.1:3000',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
it('allows cross-site from env var allowlisted origin', () => {
vi.mocked(getEnvString).mockImplementation((_key: string, defaultValue?: string) => {
if (_key === 'PROMPTFOO_CSRF_ALLOWED_ORIGINS') {
return 'http://allowed.com';
}
return defaultValue ?? '';
});
const req = mockReq({
headers: {
'sec-fetch-site': 'cross-site',
origin: 'http://allowed.com',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
it('blocks cross-site with no origin header', () => {
const req = mockReq({
headers: {
'sec-fetch-site': 'cross-site',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
// Path 2: No Sec-Fetch-Site, Origin present
describe('with Origin header only (no Sec-Fetch-Site)', () => {
it('allows POST when origin hostname matches host', () => {
const req = mockReq({
headers: {
origin: 'http://localhost:5173',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
it('blocks POST when origin hostname differs from host', () => {
const req = mockReq({
headers: {
origin: 'http://evil.com',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('allows localhost equivalence via Origin fallback', () => {
const req = mockReq({
headers: {
origin: 'http://local.promptfoo.app:5173',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
});
// Path 3: No browser headers
describe('non-browser clients (no Sec-Fetch-Site, no Origin)', () => {
it('allows POST with no browser headers', () => {
const req = mockReq({
headers: { host: 'localhost:15500' },
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).toHaveBeenCalled();
});
});
// Other mutating methods
describe('other mutating methods', () => {
it.each(['DELETE', 'PUT', 'PATCH'])('blocks cross-site %s from evil.com', (method) => {
const req = mockReq({
method,
headers: {
'sec-fetch-site': 'cross-site',
origin: 'http://evil.com',
host: 'localhost:15500',
},
});
const res = mockRes();
csrfProtection(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
// x-forwarded-host is NOT trusted (attacker-controllable)
describe('x-forwarded-host', () => {
it('ignores x-forwarded-host and uses Host header for origin comparison', () => {
const req = mockReq({
headers: {
origin: 'http://evil.com',
host: 'localhost:15500',
'x-forwarded-host': 'evil.com',
},
});
const res = mockRes();
csrfProtection(req, res, next);
// Should block: origin (evil.com) !== host (localhost), even though
// x-forwarded-host matches origin. The header is attacker-controllable.
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { Request, Response } from 'express';
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { updateResult } from '../../src/util/database';
// Mock dependencies
vi.mock('../../src/util/database');
// Import the handler directly to avoid Express overhead
const patchHandler = (req: Request, res: Response): void => {
const id = req.params.id as string;
const { table, config } = req.body;
if (!id) {
res.status(400).json({ error: 'Missing id' });
return;
}
try {
updateResult(id, config, table);
res.json({ message: 'Eval updated successfully' });
} catch {
res.status(500).json({ error: 'Failed to update eval table' });
}
};
describe('evalRouter - PATCH /:id', () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeEach(() => {
vi.clearAllMocks();
// Create lightweight mocks for Request and Response
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
mockRes = {
json: jsonMock,
status: statusMock,
};
mockReq = {
params: {},
body: {},
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should update eval config including description', async () => {
const evalId = 'test-eval-id';
const newConfig = {
description: 'Updated Test Description',
otherField: 'value',
};
mockReq.params = { id: evalId };
mockReq.body = { config: newConfig };
// Mock updateResult to resolve immediately
(updateResult as Mock).mockResolvedValue(undefined);
// Call the handler directly
patchHandler(mockReq as Request, mockRes as Response);
expect(jsonMock).toHaveBeenCalledWith({ message: 'Eval updated successfully' });
expect(updateResult).toHaveBeenCalledWith(evalId, newConfig, undefined);
});
it('should return 400 if id is missing', async () => {
mockReq.params = {}; // No id
mockReq.body = { config: { description: 'New Description' } };
patchHandler(mockReq as Request, mockRes as Response);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith({ error: 'Missing id' });
});
it('should return 500 if update fails', async () => {
const evalId = 'test-eval-id';
mockReq.params = { id: evalId };
mockReq.body = { config: { description: 'New Description' } };
// Mock updateResult to throw an error
(updateResult as Mock).mockImplementation(function () {
throw new Error('Database error');
});
patchHandler(mockReq as Request, mockRes as Response);
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith({ error: 'Failed to update eval table' });
});
});
+723
View File
@@ -0,0 +1,723 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
// Mock dependencies
vi.mock('../../../src/blobs/extractor');
vi.mock('../../../src/blobs');
vi.mock('../../../src/database');
// Import after mocking
import { getBlobByHash, getBlobUrl } from '../../../src/blobs';
import { isBlobStorageEnabled } from '../../../src/blobs/extractor';
import { getDb } from '../../../src/database';
const mockedIsBlobStorageEnabled = vi.mocked(isBlobStorageEnabled);
const mockedGetBlobUrl = vi.mocked(getBlobUrl);
const mockedGetBlobByHash = vi.mocked(getBlobByHash);
const mockedGetDb = vi.mocked(getDb);
describe('Blobs Routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
describe('GET /api/blobs/:hash', () => {
const validHash = 'a'.repeat(64);
// Create chainable mock DB that returns assetResult on first .get() and referenceResult on second
function createMockDb(assetResult?: any, referenceResult?: any) {
let callCount = 0;
return {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
get: vi.fn().mockImplementation(() => {
callCount++;
return callCount === 1 ? assetResult : referenceResult;
}),
} as any;
}
function setupDbWithAssetAndReference(
asset: Record<string, unknown>,
reference: Record<string, unknown> = { evalId: 'eval-123' },
) {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockDb = createMockDb(asset, reference);
mockedGetDb.mockReturnValue(mockDb);
}
function createBlobResponse(mimeType: string, sizeBytes: number) {
return {
data: Buffer.alloc(sizeBytes),
metadata: {
mimeType,
sizeBytes,
createdAt: new Date().toISOString(),
provider: 'local',
key: validHash,
},
};
}
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return 404 when blob storage is disabled', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(false);
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Blob storage disabled' });
});
it.each([
['too short', 'abc123'],
['invalid characters', 'g'.repeat(64)],
['too long', 'a'.repeat(65)],
])('should return 400 for invalid hash (%s)', async (_label, hash) => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const response = await api.get(`/api/blobs/${hash}`);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 404 when blob asset not found in DB', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
mockedGetDb.mockReturnValue(createMockDb(undefined));
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Blob not found' });
});
it('should return 403 when no reference exists', async () => {
const mockAsset = {
hash: validHash,
mimeType: 'image/png',
sizeBytes: 1024,
provider: 'local',
};
mockedIsBlobStorageEnabled.mockReturnValue(true);
mockedGetDb.mockReturnValue(createMockDb(mockAsset, undefined));
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Not authorized to access this blob' });
});
it('should redirect 302 when presigned URL is available', async () => {
setupDbWithAssetAndReference({
hash: validHash,
mimeType: 'image/png',
sizeBytes: 1024,
provider: 's3',
});
const presignedUrl = 'https://s3.amazonaws.com/bucket/blob?signature=xyz';
mockedGetBlobUrl.mockResolvedValue(presignedUrl);
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(302);
expect(response.header.location).toBe(presignedUrl);
expect(mockedGetBlobUrl).toHaveBeenCalledWith(validHash);
expect(mockedGetBlobByHash).not.toHaveBeenCalled();
});
it('should serve blob data directly when no presigned URL', async () => {
setupDbWithAssetAndReference({
hash: validHash,
mimeType: 'image/png',
sizeBytes: 1024,
provider: 'local',
});
mockedGetBlobUrl.mockResolvedValue(null);
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('image/png', 1024));
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe('image/png');
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
expect(response.header['accept-ranges']).toBe('none');
// Content-Length may be absent if response is gzipped (Express uses transfer-encoding: chunked)
expect(
response.header['content-length'] === '1024' ||
response.header['transfer-encoding'] === 'chunked',
).toBe(true);
expect(mockedGetBlobByHash).toHaveBeenCalledWith(validHash);
});
it('should use fallback MIME type for invalid MIME types', async () => {
setupDbWithAssetAndReference(
{ hash: validHash, mimeType: 'audio/wav.html', sizeBytes: 2048, provider: 'local' },
{ evalId: 'eval-456' },
);
mockedGetBlobUrl.mockResolvedValue(null);
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('audio/wav.html', 2048));
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe('application/octet-stream');
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
expect(response.header['accept-ranges']).toBe('none');
});
it('should use blob metadata MIME type when available', async () => {
setupDbWithAssetAndReference(
{ hash: validHash, mimeType: 'image/png', sizeBytes: 1024, provider: 'local' },
{ evalId: 'eval-789' },
);
mockedGetBlobUrl.mockResolvedValue(null);
// Blob metadata has different MIME type and size than the asset record
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('image/jpeg', 2048));
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe('image/jpeg');
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
expect(response.header['accept-ranges']).toBe('none');
// Content-Length may be absent if response is gzipped
expect(
response.header['content-length'] === '2048' ||
response.header['transfer-encoding'] === 'chunked',
).toBe(true);
});
it('should return 404 when getBlobByHash throws error', async () => {
setupDbWithAssetAndReference(
{ hash: validHash, mimeType: 'text/plain', sizeBytes: 512, provider: 'local' },
{ evalId: 'eval-error' },
);
mockedGetBlobUrl.mockResolvedValue(null);
mockedGetBlobByHash.mockRejectedValue(new Error('File system error'));
const response = await api.get(`/api/blobs/${validHash}`);
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Blob not found' });
});
});
describe('GET /api/blobs/library', () => {
/**
* Create a mock DB for library queries.
* The /library route executes 3 chained queries:
* 1. COUNT query via .get() → { count: number }
* 2. uniqueHashes query via .all() → [{ hash: string }]
* 3. items detail query via .all() → full item rows
*
* We track calls to .get() and .all() to return the right data.
*/
function createLibraryMockDb(
countResult: { count: number },
uniqueHashes: Array<{ hash: string }>,
items: any[],
) {
let getAllCallCount = 0;
return {
select: vi.fn().mockReturnThis(),
selectDistinct: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
get: vi.fn().mockReturnValue(countResult),
all: vi.fn().mockImplementation(() => {
getAllCallCount++;
// First .all() call: uniqueHashes query
// Second .all() call: items detail query
return getAllCallCount === 1 ? uniqueHashes : items;
}),
} as any;
}
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return empty list with blobStorageEnabled=false when blob storage is disabled', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(false);
const response = await api.get('/api/blobs/library');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: { items: [], total: 0, hasMore: false, blobStorageEnabled: false },
});
});
it('should return 400 for invalid query parameters', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const response = await api.get('/api/blobs/library?limit=abc');
expect(response.status).toBe(400);
expect(response.body).toEqual({
success: false,
error: 'Invalid query parameters',
});
});
it('should return 400 for invalid type filter', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const response = await api.get('/api/blobs/library?type=invalid');
expect(response.status).toBe(400);
expect(response.body).toEqual({
success: false,
error: 'Invalid query parameters',
});
});
it('should return empty list when no items match', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockDb = createLibraryMockDb({ count: 0 }, [], []);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.items).toEqual([]);
expect(response.body.data.total).toBe(0);
expect(response.body.data.hasMore).toBe(false);
});
it('should return items with correct response shape (list mode)', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const hash1 = 'a'.repeat(64);
const items = [
{
hash: hash1,
mimeType: 'image/png',
sizeBytes: 1024,
createdAt: '2025-01-01 00:00:00',
evalId: 'eval-1',
testIdx: 0,
promptIdx: 0,
location: 'response',
kind: 'image',
evalDescription: 'Test eval',
provider: { id: 'openai:gpt-4', label: 'GPT-4' },
success: true,
score: 0.9,
},
];
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.total).toBe(1);
expect(response.body.data.hasMore).toBe(false);
const item = response.body.data.items[0];
expect(item.hash).toBe(hash1);
expect(item.mimeType).toBe('image/png');
expect(item.sizeBytes).toBe(1024);
expect(item.kind).toBe('image');
expect(item.url).toBe(`/api/blobs/${hash1}`);
expect(item.context.evalId).toBe('eval-1');
expect(item.context.evalDescription).toBe('Test eval');
expect(item.context.provider).toBe('GPT-4');
expect(item.context.pass).toBe(true);
expect(item.context.score).toBe(0.9);
// Detail-only fields should NOT be in list responses
expect(item.context.prompt).toBeUndefined();
expect(item.context.variables).toBeUndefined();
expect(item.context.graderResults).toBeUndefined();
expect(item.context.latencyMs).toBeUndefined();
expect(item.context.cost).toBeUndefined();
});
it('should return detail fields when hash filter is provided', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const hash1 = 'a'.repeat(64);
const items = [
{
hash: hash1,
mimeType: 'image/png',
sizeBytes: 1024,
createdAt: '2025-01-01 00:00:00',
evalId: 'eval-1',
testIdx: 0,
promptIdx: 0,
location: 'response',
kind: 'image',
evalDescription: 'Test eval',
provider: { id: 'openai:gpt-4', label: 'GPT-4' },
prompt: { raw: 'Generate an image' },
success: true,
score: 0.9,
gradingResult: null,
testCase: { vars: { prompt: 'test' } },
latencyMs: 500,
cost: 0.01,
},
];
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get(`/api/blobs/library?hash=${hash1}`);
expect(response.status).toBe(200);
const item = response.body.data.items[0];
expect(item.context.prompt).toBe('Generate an image');
expect(item.context.variables).toEqual({ prompt: 'test' });
expect(item.context.latencyMs).toBe(500);
expect(item.context.cost).toBe(0.01);
});
it('should handle string provider format (legacy)', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const hash1 = 'b'.repeat(64);
const items = [
{
hash: hash1,
mimeType: 'audio/wav',
sizeBytes: 2048,
createdAt: 1700000000,
evalId: 'eval-2',
testIdx: 0,
promptIdx: 0,
location: null,
kind: null,
evalDescription: null,
provider: 'openai:tts-1',
prompt: null,
success: null,
score: null,
gradingResult: null,
testCase: null,
latencyMs: null,
cost: null,
},
];
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library');
expect(response.status).toBe(200);
const item = response.body.data.items[0];
expect(item.context.provider).toBe('openai:tts-1');
// Kind should be derived from mimeType when not set in reference
expect(item.kind).toBe('audio');
});
it('should indicate hasMore when more items exist beyond the page', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const hash1 = 'c'.repeat(64);
const items = [
{
hash: hash1,
mimeType: 'image/jpeg',
sizeBytes: 512,
createdAt: '2025-06-01 12:00:00',
evalId: 'eval-3',
testIdx: 0,
promptIdx: 0,
location: null,
kind: 'image',
evalDescription: null,
provider: null,
prompt: null,
success: null,
score: null,
gradingResult: null,
testCase: null,
latencyMs: null,
cost: null,
},
];
// Total is 5 but only 1 returned (limit=1, offset=0)
const mockDb = createLibraryMockDb({ count: 5 }, [{ hash: hash1 }], items);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library?limit=1');
expect(response.status).toBe(200);
expect(response.body.data.total).toBe(5);
expect(response.body.data.hasMore).toBe(true);
expect(response.body.data.items).toHaveLength(1);
});
it('should accept valid filter parameters', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockDb = createLibraryMockDb({ count: 0 }, [], []);
mockedGetDb.mockReturnValue(mockDb);
// Type filter
const res1 = await api.get('/api/blobs/library?type=image');
expect(res1.status).toBe(200);
// Sort parameters
const res2 = await api.get('/api/blobs/library?sortField=sizeBytes&sortOrder=asc');
expect(res2.status).toBe(200);
// Eval filter
const res3 = await api.get('/api/blobs/library?evalId=some-eval-id');
expect(res3.status).toBe(200);
// Hash filter (deep link)
const hash = 'd'.repeat(64);
const res4 = await api.get(`/api/blobs/library?hash=${hash}`);
expect(res4.status).toBe(200);
});
it('should handle grading results in detail response', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const hash1 = 'e'.repeat(64);
const items = [
{
hash: hash1,
mimeType: 'image/png',
sizeBytes: 1024,
createdAt: '2025-01-01 00:00:00',
evalId: 'eval-grading',
testIdx: 0,
promptIdx: 0,
location: null,
kind: 'image',
evalDescription: null,
provider: null,
prompt: null,
success: true,
score: 0.75,
gradingResult: {
componentResults: [
{ pass: true, score: 1.0, reason: 'Looks good', assertion: { type: 'human' } },
{ pass: false, score: 0.5, reason: 'Low quality' },
],
},
testCase: null,
latencyMs: null,
cost: null,
},
];
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
mockedGetDb.mockReturnValue(mockDb);
// Use hash filter to trigger detail mode
const response = await api.get(`/api/blobs/library?hash=${hash1}`);
expect(response.status).toBe(200);
const item = response.body.data.items[0];
expect(item.context.graderResults).toHaveLength(2);
expect(item.context.graderResults[0]).toEqual({
name: 'human',
pass: true,
score: 1.0,
reason: 'Looks good',
});
expect(item.context.graderResults[1]).toEqual({
name: 'Grader 2',
pass: false,
score: 0.5,
reason: 'Low quality',
});
});
it('should return 500 on database error', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
mockedGetDb.mockImplementation(() => {
throw new Error('Database connection failed');
});
const response = await api.get('/api/blobs/library');
expect(response.status).toBe(500);
});
});
describe('GET /api/blobs/library/evals', () => {
function createEvalsMockDb(evals: any[]) {
return {
selectDistinct: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
all: vi.fn().mockReturnValue(evals),
} as any;
}
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return empty array when blob storage is disabled', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(false);
const response = await api.get('/api/blobs/library/evals');
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: true, data: [] });
});
it('should return 400 for invalid query parameters', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const response = await api.get('/api/blobs/library/evals?limit=abc');
expect(response.status).toBe(400);
expect(response.body).toEqual({
success: false,
error: 'Invalid query parameters',
});
});
it('should return evals with correct shape', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockEvals = [
{
evalId: 'eval-abc-123',
description: 'Image generation test',
createdAt: '2025-06-15 10:30:00',
},
{
evalId: 'eval-def-456',
description: null,
createdAt: 1700000000,
},
];
const mockDb = createEvalsMockDb(mockEvals);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library/evals');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveLength(2);
// First eval with description
expect(response.body.data[0].evalId).toBe('eval-abc-123');
expect(response.body.data[0].description).toBe('Image generation test');
expect(response.body.data[0].createdAt).toBeDefined();
// Second eval falls back to truncated ID
expect(response.body.data[1].evalId).toBe('eval-def-456');
expect(response.body.data[1].description).toBe('Eval eval-def');
});
it('should respect custom limit parameter', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockDb = createEvalsMockDb([]);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library/evals?limit=5');
expect(response.status).toBe(200);
expect(mockDb.limit).toHaveBeenCalledWith(5);
});
it('should return 500 on database error', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
mockedGetDb.mockImplementation(() => {
throw new Error('Database connection failed');
});
const response = await api.get('/api/blobs/library/evals');
expect(response.status).toBe(500);
});
it('should pass search query to where clause', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockDb = createEvalsMockDb([]);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library/evals?search=test');
expect(response.status).toBe(200);
expect(mockDb.where).toHaveBeenCalled();
});
it('should not add where clause when search is empty', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const mockDb = createEvalsMockDb([]);
mockedGetDb.mockReturnValue(mockDb);
const response = await api.get('/api/blobs/library/evals');
expect(response.status).toBe(200);
// where is called with undefined (no conditions)
expect(mockDb.where).toHaveBeenCalledWith(undefined);
});
it('should reject search strings exceeding max length', async () => {
mockedIsBlobStorageEnabled.mockReturnValue(true);
const longSearch = 'a'.repeat(201);
const response = await api.get(`/api/blobs/library/evals?search=${longSearch}`);
expect(response.status).toBe(400);
});
});
});
+176
View File
@@ -0,0 +1,176 @@
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getDb } from '../../../src/database/index';
import { configsTable } from '../../../src/database/tables';
import { runDbMigrations } from '../../../src/migrate';
import { createApp } from '../../../src/server/server';
import {
CreateConfigResponseSchema,
GetConfigResponseSchema,
ListConfigsByTypeResponseSchema,
ListConfigsResponseSchema,
} from '../../../src/types/api/configs';
describe('configs routes', () => {
let app: ReturnType<typeof createApp>;
const testConfigIds = new Set<string>();
beforeAll(async () => {
await runDbMigrations();
});
beforeEach(() => {
app = createApp();
});
afterEach(async () => {
const db = await getDb();
for (const id of testConfigIds) {
try {
await db.delete(configsTable).where(eq(configsTable.id, id));
} catch {
// Ignore cleanup errors
}
}
testConfigIds.clear();
});
describe('POST /api/configs', () => {
it('should create a config and return id and createdAt', async () => {
const res = await request(app)
.post('/api/configs')
.send({
name: 'test-config',
type: 'eval',
config: { prompts: ['hello'] },
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('id');
expect(res.body).toHaveProperty('createdAt');
testConfigIds.add(res.body.id);
const parsed = CreateConfigResponseSchema.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it.each([
['missing name', { type: 'eval', config: {} }],
['missing type', { name: 'test-config', config: {} }],
['empty name', { name: '', type: 'eval', config: {} }],
['missing config', { name: 'test-config', type: 'eval' }],
['null config', { name: 'test-config', type: 'eval', config: null }],
])('should return 400 for %s', async (_label, body) => {
const res = await request(app).post('/api/configs').send(body);
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
});
describe('GET /api/configs', () => {
it('should return all configs with valid response schema', async () => {
const createRes = await request(app)
.post('/api/configs')
.send({
name: 'list-test',
type: 'eval',
config: { foo: 'bar' },
});
testConfigIds.add(createRes.body.id);
const res = await request(app).get('/api/configs');
expect(res.status).toBe(200);
expect(res.body.configs.length).toBeGreaterThanOrEqual(1);
const parsed = ListConfigsResponseSchema.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it('should filter configs by type query parameter', async () => {
const createRes = await request(app).post('/api/configs').send({
name: 'filter-test',
type: 'redteam',
config: {},
});
testConfigIds.add(createRes.body.id);
const res = await request(app).get('/api/configs?type=redteam');
expect(res.status).toBe(200);
expect(res.body.configs.length).toBeGreaterThanOrEqual(1);
for (const config of res.body.configs) {
expect(config.type).toBe('redteam');
}
});
it('should return 400 for empty type query parameter', async () => {
const res = await request(app).get('/api/configs?type=');
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
});
describe('GET /api/configs/:type', () => {
it('should return configs filtered by type with valid response schema', async () => {
const createRes = await request(app)
.post('/api/configs')
.send({
name: 'type-filter-test',
type: 'eval',
config: { hello: 'world' },
});
testConfigIds.add(createRes.body.id);
const res = await request(app).get('/api/configs/eval');
expect(res.status).toBe(200);
expect(res.body.configs.length).toBeGreaterThanOrEqual(1);
const parsed = ListConfigsByTypeResponseSchema.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it('should return empty array for unknown type', async () => {
const res = await request(app).get('/api/configs/nonexistent-type-xyz');
expect(res.status).toBe(200);
expect(res.body.configs).toEqual([]);
});
});
describe('GET /api/configs/:type/:id', () => {
it('should return a specific config with valid response schema', async () => {
const createRes = await request(app)
.post('/api/configs')
.send({
name: 'get-test',
type: 'eval',
config: { key: 'value' },
});
const configId = createRes.body.id;
testConfigIds.add(configId);
const res = await request(app).get(`/api/configs/eval/${configId}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(configId);
expect(res.body.name).toBe('get-test');
expect(res.body.type).toBe('eval');
expect(res.body.config).toEqual({ key: 'value' });
const parsed = GetConfigResponseSchema.safeParse(res.body);
expect(parsed.success).toBe(true);
});
it('should return 404 for non-existent config', async () => {
const res = await request(app).get('/api/configs/eval/non-existent-id');
expect(res.status).toBe(404);
expect(res.body).toHaveProperty('error', 'Config not found');
});
});
});
+600
View File
@@ -0,0 +1,600 @@
import { afterEach, beforeEach, describe, expect, it, Mock, MockedFunction, vi } from 'vitest';
import { createCompletedPrompt, createEvaluateTableOutput } from '../../factories/eval';
import type { Request, Response } from 'express';
// Mock dependencies first
vi.mock('../../../src/database', async (importOriginal) => {
return {
...(await importOriginal()),
getDb: vi.fn(),
};
});
vi.mock('../../../src/models/eval');
vi.mock('../../../src/util/eval/evalTableUtils');
vi.mock('../../../src/server/utils/downloadHelpers', async (importOriginal) => {
return {
...(await importOriginal()),
setDownloadHeaders: vi.fn(),
};
});
// Import after mocking
import Eval from '../../../src/models/eval';
import { setDownloadHeaders } from '../../../src/server/utils/downloadHelpers';
import { EVAL_TABLE_MAX_PAGE_SIZE, EvalSchemas } from '../../../src/types/api/eval';
import { evalTableToCsv, evalTableToJson } from '../../../src/util/eval/evalTableUtils';
// Setup mocked functions
const mockedEvalFindById = vi.fn() as MockedFunction<typeof Eval.findById>;
const mockedEvalTableToCsv = evalTableToCsv as MockedFunction<typeof evalTableToCsv>;
const mockedEvalTableToJson = evalTableToJson as MockedFunction<typeof evalTableToJson>;
// Override the mocked modules
(Eval as any).findById = mockedEvalFindById;
describe('evalRouter - GET /:id/table with export formats', () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let jsonMock: Mock;
let sendMock: Mock;
let statusMock: Mock;
const mockTable = {
head: {
vars: ['var1', 'var2'],
prompts: [
createCompletedPrompt('test prompt', {
provider: 'openai',
label: 'prompt1',
display: 'test',
}),
],
},
body: [
{
test: { vars: { var1: 'value1', var2: 'value2' } },
testIdx: 0,
vars: ['value1', 'value2'],
outputs: [
createEvaluateTableOutput({
text: 'output text',
id: 'output-id',
latencyMs: 100,
provider: 'openai:gpt-3.5-turbo',
gradingResult: {
pass: true,
score: 1,
reason: 'Test passed',
comment: 'Good response',
},
metadata: {
redteamHistory: ['attempt1', 'attempt2'],
messages: [
{ role: 'user', content: 'test' },
{ role: 'assistant', content: 'response' },
],
},
}),
],
},
],
totalCount: 1,
filteredCount: 1,
id: 'test-eval-id',
};
const mockConfig = {
redteam: {
strategies: ['jailbreak'],
},
};
beforeEach(() => {
vi.clearAllMocks();
jsonMock = vi.fn();
sendMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
mockRes = {
json: jsonMock,
send: sendMock,
status: statusMock,
setHeader: vi.fn(),
} as Partial<Response>;
mockReq = {
params: { id: 'test-eval-id' },
query: {},
} as Partial<Request>;
// Setup Eval mock
const getTablePageMock = vi.fn() as any;
getTablePageMock.mockResolvedValue(mockTable);
const mockEval = {
config: mockConfig,
author: 'test-author',
version: vi.fn().mockReturnValue('1.0.0'),
getTablePage: getTablePageMock,
} as unknown as Eval;
mockedEvalFindById.mockResolvedValue(mockEval);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return CSV when format=csv is specified', async () => {
mockReq.query = { format: 'csv' };
const mockCsvData = 'var1,var2,[openai] prompt1\nvalue1,value2,[PASS] output text';
mockedEvalTableToCsv.mockReturnValue(mockCsvData);
// Simulate the route handler (simplified version)
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
const csvData = mockedEvalTableToCsv(table, {
isRedteam: !!eval_!.config?.redteam,
});
setDownloadHeaders(mockRes as Response, 'test-eval-id-results.csv', 'text/csv');
(mockRes as Response).send(csvData);
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), expect.anything());
expect(sendMock).toHaveBeenCalledWith(mockCsvData);
});
it('should return JSON when format=json is specified', async () => {
mockReq.query = { format: 'json' };
const mockJsonData = { table: mockTable };
mockedEvalTableToJson.mockReturnValue(mockJsonData);
// Simulate the route handler (simplified version)
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
const jsonData = mockedEvalTableToJson(table);
setDownloadHeaders(mockRes as Response, 'test-eval-id-results.json', 'application/json');
(mockRes as Response).json(jsonData);
expect(mockedEvalTableToJson).toHaveBeenCalledWith(expect.anything());
expect(jsonMock).toHaveBeenCalledWith(mockJsonData);
});
it('should include red team conversation columns in CSV for red team evaluations', async () => {
mockReq.query = { format: 'csv' };
// Mock the CSV generation to verify red team columns are included
const expectedCsvWithRedteam =
'var1,var2,[openai] prompt1,[openai] prompt1 - Grader Reason,[openai] prompt1 - Comment,Messages,RedteamHistory\n' +
'value1,value2,[PASS] output text,Test passed,Good response,"[{\\"role\\":\\"user\\",\\"content\\":\\"test\\"},{\\"role\\":\\"assistant\\",\\"content\\":\\"response\\"}]","[\\"attempt1\\",\\"attempt2\\"]"';
mockedEvalTableToCsv.mockReturnValue(expectedCsvWithRedteam);
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
const csvData = mockedEvalTableToCsv(table, {
isRedteam: !!eval_!.config?.redteam,
});
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), expect.anything());
expect(csvData).toContain('Messages');
expect(csvData).toContain('RedteamHistory');
});
it('should return standard table response when no format is specified', async () => {
mockReq.query = {}; // No format specified
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: 50, // Default limit
filterMode: 'all',
searchQuery: '',
filters: [],
});
// Simulate standard response
(mockRes as Response).json({
table,
totalCount: table.totalCount,
filteredCount: table.filteredCount,
config: eval_!.config,
author: eval_!.author,
version: eval_!.version(),
});
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
table: mockTable,
totalCount: 1,
filteredCount: 1,
config: mockConfig,
author: 'test-author',
version: '1.0.0',
}),
);
expect(sendMock).not.toHaveBeenCalled();
});
it('should handle CSV export for non-redteam evaluations', async () => {
// Setup eval without redteam config
const nonRedteamConfig = { someConfig: 'value' };
const getTablePageMockNoRedteam = vi.fn() as any;
getTablePageMockNoRedteam.mockResolvedValue(mockTable);
const mockEvalNoRedteam = {
config: nonRedteamConfig,
author: 'test-author',
version: vi.fn().mockReturnValue('1.0.0'),
getTablePage: getTablePageMockNoRedteam,
} as unknown as Eval;
mockedEvalFindById.mockResolvedValue(mockEvalNoRedteam);
mockReq.query = { format: 'csv' };
const mockCsvData = 'var1,var2,[openai] prompt1\nvalue1,value2,[PASS] output text';
mockedEvalTableToCsv.mockReturnValue(mockCsvData);
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
const csvData = mockedEvalTableToCsv(table, {
isRedteam: !!eval_!.config?.redteam,
});
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), expect.anything());
// Should not contain red team columns
expect(csvData).not.toContain('Messages');
expect(csvData).not.toContain('RedteamHistory');
});
it('should handle different red team metadata types', async () => {
const tableWithMultipleRedteamTypes = {
...mockTable,
body: [
{
...mockTable.body[0],
outputs: [
{
pass: true,
text: 'output 1',
metadata: {
messages: [
{ role: 'system', content: 'You are helpful' },
{ role: 'user', content: 'Hello' },
],
},
},
],
},
{
test: { vars: { var1: 'val3', var2: 'val4' } },
testIdx: 1,
vars: ['val3', 'val4'],
outputs: [
{
pass: false,
text: 'output 2',
metadata: {
redteamHistory: ['attempt 1', 'attempt 2', 'attempt 3'],
},
},
],
},
{
test: { vars: { var1: 'val5', var2: 'val6' } },
testIdx: 2,
vars: ['val5', 'val6'],
outputs: [
{
pass: false,
text: 'output 3',
metadata: {
redteamTreeHistory: 'root->branch1->leaf1\nroot->branch2->leaf2',
},
},
],
},
],
};
const getTablePageMockWithTypes = vi.fn() as any;
getTablePageMockWithTypes.mockResolvedValue({
...tableWithMultipleRedteamTypes,
id: 'test-eval-id',
});
const mockEvalWithTypes = {
config: mockConfig,
author: 'test-author',
version: vi.fn().mockReturnValue('1.0.0'),
getTablePage: getTablePageMockWithTypes,
} as unknown as Eval;
mockedEvalFindById.mockResolvedValue(mockEvalWithTypes);
mockReq.query = { format: 'csv' };
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
mockedEvalTableToCsv(table, { isRedteam: !!eval_!.config?.redteam });
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), {
isRedteam: true,
});
});
it('should ignore pagination parameters for exports if limit exceeds the table page size', async () => {
mockReq.query = {
format: 'csv',
limit: String(EVAL_TABLE_MAX_PAGE_SIZE + 1),
offset: '50',
};
const query = EvalSchemas.Table.Query.parse(mockReq.query);
const eval_ = await Eval.findById(mockReq.params!.id as string);
// When format is specified, should ignore pagination and get all data
await eval_!.getTablePage({
offset: query.format ? 0 : query.offset,
limit: query.format ? Number.MAX_SAFE_INTEGER : query.limit,
filterMode: query.filterMode,
searchQuery: query.search,
filters: query.filter,
});
expect(eval_!.getTablePage).toHaveBeenCalledWith(
expect.objectContaining({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
}),
);
});
it('should handle filter parameters in exports', async () => {
mockReq.query = {
format: 'csv',
filterMode: 'failures',
search: 'error',
filter: ['provider:openai', 'status:fail'],
};
const eval_ = await Eval.findById(mockReq.params!.id as string);
await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'failures',
searchQuery: 'error',
filters: ['provider:openai', 'status:fail'],
});
expect(eval_!.getTablePage).toHaveBeenCalledWith(
expect.objectContaining({
filterMode: 'failures',
searchQuery: 'error',
filters: ['provider:openai', 'status:fail'],
}),
);
});
it('should return 404 when evaluation not found', async () => {
mockedEvalFindById.mockResolvedValue(undefined);
const eval_ = await Eval.findById(mockReq.params!.id as string);
if (!eval_) {
(mockRes as Response).status(404).json({ error: 'Eval not found' });
}
expect(statusMock).toHaveBeenCalledWith(404);
expect(jsonMock).toHaveBeenCalledWith({ error: 'Eval not found' });
});
it('should handle empty table data in CSV export', async () => {
const emptyTable = {
head: {
vars: ['var1'],
prompts: [createCompletedPrompt('test', { provider: 'openai', label: 'test' })],
},
body: [],
totalCount: 0,
filteredCount: 0,
};
const getTablePageMockEmpty = vi.fn() as any;
getTablePageMockEmpty.mockResolvedValue({ ...emptyTable, id: 'test-eval-id' });
const mockEvalEmpty = {
config: mockConfig,
author: 'test-author',
version: vi.fn().mockReturnValue('1.0.0'),
getTablePage: getTablePageMockEmpty,
} as unknown as Eval;
mockedEvalFindById.mockResolvedValue(mockEvalEmpty);
mockReq.query = { format: 'csv' };
const mockCsvData = 'var1,[openai] test\n';
mockedEvalTableToCsv.mockReturnValue(mockCsvData);
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
const csvData = mockedEvalTableToCsv(table, {
isRedteam: !!eval_!.config?.redteam,
});
setDownloadHeaders(mockRes as Response, 'test-eval-id-results.csv', 'text/csv');
(mockRes as Response).send(csvData);
expect(sendMock).toHaveBeenCalledWith(mockCsvData);
});
it('should properly escape special characters in CSV', async () => {
const tableWithSpecialChars = {
...mockTable,
body: [
{
test: { vars: { var1: 'value,with,commas', var2: 'value"with"quotes' } },
testIdx: 0,
vars: ['value,with,commas', 'value"with"quotes'],
outputs: [
{
pass: true,
text: 'Output\nwith\nnewlines',
metadata: {
messages: [{ role: 'user', content: 'Message with "quotes" and, commas' }],
},
},
],
},
],
};
const getTablePageMockSpecial = vi.fn() as any;
getTablePageMockSpecial.mockResolvedValue({ ...tableWithSpecialChars, id: 'test-eval-id' });
const mockEvalSpecial = {
config: mockConfig,
author: 'test-author',
version: vi.fn().mockReturnValue('1.0.0'),
getTablePage: getTablePageMockSpecial,
} as unknown as Eval;
mockedEvalFindById.mockResolvedValue(mockEvalSpecial);
mockReq.query = { format: 'csv' };
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
mockedEvalTableToCsv(table, { isRedteam: !!eval_!.config?.redteam });
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), {
isRedteam: true,
});
});
it('should handle very large datasets efficiently', async () => {
// Create a large table with many rows
const largeBody = Array.from({ length: 10000 }, (_, i) => ({
test: { vars: { var1: `val${i}`, var2: `val${i + 1}` } },
testIdx: i,
vars: [`val${i}`, `val${i + 1}`],
outputs: [
{
pass: i % 2 === 0,
text: `Output ${i}`,
metadata: {
messages: [{ role: 'user', content: `Message ${i}` }],
},
},
],
}));
const largeTable = {
head: mockTable.head,
body: largeBody,
totalCount: 10000,
filteredCount: 10000,
};
const getTablePageMockLarge = vi.fn() as any;
getTablePageMockLarge.mockResolvedValue({ ...largeTable, id: 'test-eval-id' });
const mockEvalLarge = {
config: mockConfig,
author: 'test-author',
version: vi.fn().mockReturnValue('1.0.0'),
getTablePage: getTablePageMockLarge,
} as unknown as Eval;
mockedEvalFindById.mockResolvedValue(mockEvalLarge);
mockReq.query = { format: 'csv' };
mockedEvalTableToCsv.mockReturnValue('large csv data');
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
mockedEvalTableToCsv(table, { isRedteam: !!eval_!.config?.redteam });
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), {
isRedteam: true,
});
});
it('should set correct content-type headers for different formats', async () => {
// Test CSV
mockReq.query = { format: 'csv' };
mockedEvalTableToCsv.mockReturnValue('csv data');
const eval_ = await Eval.findById(mockReq.params!.id as string);
const table = await eval_!.getTablePage({
offset: 0,
limit: Number.MAX_SAFE_INTEGER,
filterMode: 'all',
searchQuery: '',
filters: [],
});
const csvData = mockedEvalTableToCsv(table, {
isRedteam: !!eval_!.config?.redteam,
});
// Verify CSV generation
expect(mockedEvalTableToCsv).toHaveBeenCalled();
expect(csvData).toBe('csv data');
// Test JSON
vi.clearAllMocks();
mockReq.query = { format: 'json' };
mockedEvalTableToJson.mockReturnValue({ data: 'json' });
const jsonData = mockedEvalTableToJson(table);
// Verify JSON generation
expect(mockedEvalTableToJson).toHaveBeenCalled();
expect(jsonData).toEqual({ data: 'json' });
});
});
@@ -0,0 +1,406 @@
/**
* Integration tests for GET /api/eval/:id/table with filtered metrics.
*
* Tests that the API route correctly:
* 1. Detects active filters
* 2. Calls getFilteredMetrics when appropriate
* 3. Includes filteredMetrics in the response
* 4. Handles errors gracefully
*/
import type { Server } from 'node:http';
import { sql } from 'drizzle-orm';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getDb } from '../../../src/database/index';
import { runDbMigrations } from '../../../src/migrate';
import { createApp } from '../../../src/server/server';
import EvalFactory from '../../factories/evalFactory';
describe('GET /api/eval/:id/table - Filtered Metrics Integration', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await runDbMigrations();
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(async () => {
const db = await getDb();
await db.run('DELETE FROM eval_results');
await db.run('DELETE FROM evals_to_datasets');
await db.run('DELETE FROM evals_to_prompts');
await db.run('DELETE FROM evals_to_tags');
await db.run('DELETE FROM evals');
// Reset mocks
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('Filtered metrics behavior', () => {
it('should include filteredMetrics when filters are active', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('filteredMetrics');
expect(response.body.filteredMetrics).not.toBeNull();
expect(Array.isArray(response.body.filteredMetrics)).toBe(true);
expect(response.body.filteredMetrics).toHaveLength(1); // 1 prompt
// Verify structure
expect(response.body.filteredMetrics[0]).toMatchObject({
score: expect.any(Number),
testPassCount: expect.any(Number),
testFailCount: expect.any(Number),
testErrorCount: expect.any(Number),
assertPassCount: expect.any(Number),
assertFailCount: expect.any(Number),
totalLatencyMs: expect.any(Number),
tokenUsage: expect.any(Object),
namedScores: expect.any(Object),
namedScoresCount: expect.any(Object),
cost: expect.any(Number),
});
});
it('should NOT include filteredMetrics when NO filters are active', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'all' }); // No filters
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).toBeNull();
});
});
describe('Filter detection', () => {
it('should detect filterMode as active filter', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'passes' });
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
});
it('should detect searchQuery as active filter', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
searchableContent: 'searchable',
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ search: 'searchable' });
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
});
it('should detect custom filters as active', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
withNamedScores: true,
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({
filter: JSON.stringify({
logicOperator: 'and',
type: 'metric',
operator: 'equals',
value: 'accuracy',
}),
});
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
});
it('should detect multiple filters as active', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
withNamedScores: true,
searchableContent: 'searchable',
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({
filterMode: 'failures',
search: 'searchable',
filter: JSON.stringify({
logicOperator: 'and',
type: 'metric',
operator: 'equals',
value: 'accuracy',
}),
});
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
});
});
describe('Metrics correctness', () => {
it('should return correct metrics for error filter', async () => {
const eval_ = await EvalFactory.create({
numResults: 15,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
const metrics = response.body.filteredMetrics[0];
expect(metrics.testErrorCount).toBeGreaterThan(0);
expect(metrics.testPassCount).toBe(0);
expect(metrics.testFailCount).toBe(0);
});
it('should return correct metrics for failure filter', async () => {
const eval_ = await EvalFactory.create({
numResults: 15,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api
.get(`/api/eval/${eval_.id}/table`)
.query({ filterMode: 'failures' });
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
const metrics = response.body.filteredMetrics[0];
expect(metrics.testFailCount).toBeGreaterThan(0);
expect(metrics.testPassCount).toBe(0);
expect(metrics.testErrorCount).toBe(0);
});
it('should return correct metrics for pass filter', async () => {
const eval_ = await EvalFactory.create({
numResults: 15,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'passes' });
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
const metrics = response.body.filteredMetrics[0];
expect(metrics.testPassCount).toBeGreaterThan(0);
expect(metrics.testFailCount).toBe(0);
expect(metrics.testErrorCount).toBe(0);
});
it('should return metrics with named scores when present', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'failure'],
withNamedScores: true,
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'passes' });
expect(response.status).toBe(200);
expect(response.body.filteredMetrics).not.toBeNull();
const metrics = response.body.filteredMetrics[0];
expect(metrics.namedScores).toHaveProperty('accuracy');
expect(metrics.namedScores).toHaveProperty('relevance');
expect(metrics.namedScoresCount).toHaveProperty('accuracy');
expect(metrics.namedScoresCount).toHaveProperty('relevance');
});
});
describe('Error handling', () => {
it('should handle nonexistent eval gracefully', async () => {
const response = await api
.get('/api/eval/nonexistent-id/table')
.query({ filterMode: 'errors' });
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error');
});
it('should return empty metrics when no results match filter', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
// Delete all results to test empty dataset handling
const db = await getDb();
await db.run(sql`DELETE FROM eval_results WHERE eval_id = ${eval_.id}`);
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
// Request should still succeed
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('table');
// filteredMetrics should be empty array (not null) for zero results
expect(response.body.filteredMetrics).not.toBeNull();
expect(Array.isArray(response.body.filteredMetrics)).toBe(true);
expect(response.body.filteredMetrics[0]).toMatchObject({
score: 0,
testPassCount: 0,
testFailCount: 0,
testErrorCount: 0,
});
});
it('should validate filteredMetrics array length matches prompts array length', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
expect(response.status).toBe(200);
// If filteredMetrics is not null, it must have the same length as prompts
if (response.body.filteredMetrics !== null) {
expect(response.body.filteredMetrics.length).toBe(response.body.table.head.prompts.length);
}
});
});
describe('Response structure', () => {
it('should include all required fields in response', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
table: expect.any(Object),
totalCount: expect.any(Number),
filteredCount: expect.any(Number),
filteredMetrics: expect.any(Array),
config: expect.any(Object),
author: null, // or expect.any(String) if author is set
version: expect.any(Number),
id: eval_.id,
});
});
it('should preserve existing behavior for export formats', async () => {
const eval_ = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
// CSV export should not include filteredMetrics
const csvResponse = await api
.get(`/api/eval/${eval_.id}/table`)
.query({ format: 'csv', filterMode: 'errors' });
expect(csvResponse.status).toBe(200);
expect(csvResponse.headers['content-type']).toContain('text/csv');
expect(typeof csvResponse.text).toBe('string');
// JSON export should not include filteredMetrics (returns table object, not array)
const jsonResponse = await api
.get(`/api/eval/${eval_.id}/table`)
.query({ format: 'json', filterMode: 'errors' });
expect(jsonResponse.status).toBe(200);
expect(jsonResponse.headers['content-type']).toContain('application/json');
// JSON export returns table structure { head, body }, not full EvalTableDTO
expect(jsonResponse.body).toHaveProperty('head');
expect(jsonResponse.body).toHaveProperty('body');
expect(jsonResponse.body).not.toHaveProperty('filteredMetrics');
});
});
describe('Pagination interaction', () => {
it('should calculate metrics for entire filtered dataset, not just current page', async () => {
const eval_ = await EvalFactory.create({
numResults: 100,
resultTypes: ['success', 'error', 'failure'],
});
// Request first page (limited results)
const response = await api
.get(`/api/eval/${eval_.id}/table`)
.query({ filterMode: 'passes', limit: 10, offset: 0 });
expect(response.status).toBe(200);
expect(response.body.table.body).toHaveLength(10); // Paginated results
// But filteredMetrics should cover ALL passes, not just the 10 on this page
const metrics = response.body.filteredMetrics[0];
expect(metrics.testPassCount).toBeGreaterThan(10);
});
});
describe('Comparison mode', () => {
it('should include filteredMetrics for base eval even when comparison evals are present', async () => {
const eval1 = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const eval2 = await EvalFactory.create({
numResults: 10,
resultTypes: ['success', 'error', 'failure'],
});
const response = await api.get(`/api/eval/${eval1.id}/table`).query({
filterMode: 'passes',
comparisonEvalIds: eval2.id,
});
expect(response.status).toBe(200);
// filteredMetrics should be calculated for the base eval (eval1)
// even when comparison evals are included in the table
expect(response.body).toHaveProperty('filteredMetrics');
expect(response.body.filteredMetrics).not.toBeNull();
expect(Array.isArray(response.body.filteredMetrics)).toBe(true);
// filteredMetrics should match the base eval's prompt count (1), not the combined count (2)
expect(response.body.filteredMetrics).toHaveLength(1);
// Verify the table includes both base and comparison prompts
expect(response.body.table.head.prompts.length).toBeGreaterThan(1);
});
});
});
+294
View File
@@ -0,0 +1,294 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies BEFORE imports
vi.mock('../../../src/node', () => ({
evaluateWithSource: vi.fn().mockResolvedValue({
toEvaluateSummary: vi.fn().mockResolvedValue({ results: [] }),
}),
}));
vi.mock('../../../src/util/sharing', () => ({
shouldShareResults: vi.fn().mockReturnValue(false),
}));
vi.mock('../../../src/models/eval', () => ({
default: {
create: vi.fn(),
findById: vi.fn(),
},
}));
vi.mock('../../../src/globalConfig/accounts');
import logger from '../../../src/logger';
import Eval from '../../../src/models/eval';
import { evaluateWithSource } from '../../../src/node';
import { createApp } from '../../../src/server/server';
import { shouldShareResults } from '../../../src/util/sharing';
const errorSpy = vi.spyOn(logger, 'error');
const mockedEvalCreate = vi.mocked(Eval.create);
const mockedEvalFindById = vi.mocked(Eval.findById);
const mockedEvaluateWithSource = vi.mocked(evaluateWithSource);
const mockedShouldShareResults = vi.mocked(shouldShareResults);
describe('Eval Routes - Sharing behavior', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
vi.resetAllMocks();
errorSpy.mockClear();
mockedEvaluateWithSource.mockResolvedValue({
toEvaluateSummary: vi.fn().mockResolvedValue({ results: [] }),
} as any);
mockedShouldShareResults.mockReturnValue(false);
});
afterEach(() => {
vi.resetAllMocks();
});
const postJob = (body: Record<string, unknown>) => api.post('/api/eval/job').send(body);
const minimalTestSuite = {
prompts: ['test prompt'],
providers: ['echo'],
tests: [{ vars: { input: 'test' } }],
};
it('should use testSuite.sharing when explicitly set to true', async () => {
await postJob({ ...minimalTestSuite, sharing: true });
// Wait for async evaluate call
await vi.waitFor(() => {
expect(mockedEvaluateWithSource).toHaveBeenCalled();
});
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
expect(evaluateArg.sharing).toBe(true);
});
it('should use testSuite.sharing when explicitly set to false', async () => {
mockedShouldShareResults.mockReturnValue(true);
await postJob({ ...minimalTestSuite, sharing: false });
await vi.waitFor(() => {
expect(mockedEvaluateWithSource).toHaveBeenCalled();
});
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
expect(evaluateArg.sharing).toBe(false);
});
it('should fall back to shouldShareResults when testSuite.sharing is undefined', async () => {
mockedShouldShareResults.mockReturnValue(true);
await postJob(minimalTestSuite);
await vi.waitFor(() => {
expect(mockedEvaluateWithSource).toHaveBeenCalled();
});
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
expect(evaluateArg.sharing).toBe(true);
expect(mockedShouldShareResults).toHaveBeenCalledWith({});
});
it('does not publish completion before saved results are ready', async () => {
let resolveSummary: ((summary: { results: never[] }) => void) | undefined;
mockedEvaluateWithSource.mockResolvedValueOnce({
id: 'eval-result-id',
toEvaluateSummary: vi.fn(
() =>
new Promise<{ results: never[] }>((resolve) => {
resolveSummary = resolve;
}),
),
} as any);
const createResponse = await postJob(minimalTestSuite);
const jobId = createResponse.body.id;
await vi.waitFor(() => {
expect(resolveSummary).toBeDefined();
});
const inProgressResponse = await api.get(`/api/eval/job/${jobId}`);
expect(inProgressResponse.body.status).toBe('in-progress');
expect(inProgressResponse.body.evalId).toBeUndefined();
resolveSummary!({ results: [] });
await vi.waitFor(async () => {
const completedResponse = await api.get(`/api/eval/job/${jobId}`);
expect(completedResponse.body).toMatchObject({
status: 'complete',
evalId: 'eval-result-id',
result: { results: [] },
});
});
});
it('publishes progress updates while a job is running', async () => {
let resolveEvaluation: ((value: any) => void) | undefined;
mockedEvaluateWithSource.mockImplementationOnce(
(_testSuite, options) =>
new Promise((resolve) => {
options?.progressCallback?.(2, 5, 0, {} as never, {} as never);
resolveEvaluation = resolve;
}),
);
const createResponse = await postJob(minimalTestSuite);
const jobId = createResponse.body.id;
const inProgressResponse = await api.get(`/api/eval/job/${jobId}`);
expect(inProgressResponse.body).toMatchObject({
status: 'in-progress',
progress: 2,
total: 5,
});
resolveEvaluation!({
id: 'eval-result-id',
toEvaluateSummary: vi.fn().mockResolvedValue({ results: [] }),
});
await vi.waitFor(async () => {
const completedResponse = await api.get(`/api/eval/job/${jobId}`);
expect(completedResponse.body.status).toBe('complete');
});
});
it('flips status to error when toEvaluateSummary rejects', async () => {
mockedEvaluateWithSource.mockResolvedValueOnce({
id: 'eval-result-id',
toEvaluateSummary: vi.fn().mockRejectedValue(new Error('summary boom')),
} as any);
const createResponse = await postJob(minimalTestSuite);
const jobId = createResponse.body.id;
await vi.waitFor(async () => {
const response = await api.get(`/api/eval/job/${jobId}`);
expect(response.body.status).toBe('error');
});
});
it('should not log the raw job body on evaluation failure', async () => {
mockedEvaluateWithSource.mockRejectedValueOnce(new Error('boom'));
const sensitiveBody = {
prompts: ['test prompt'],
providers: ['echo'],
tests: [{ vars: { input: 'secret value', apiKey: 'sk-test-12345678901234567890' } }],
};
await postJob(sensitiveBody);
await vi.waitFor(() => {
expect(errorSpy).toHaveBeenCalled();
});
expect(errorSpy).toHaveBeenCalledWith(
'Failed to eval tests',
expect.objectContaining({
body: expect.objectContaining({
prompts: ['test prompt'],
providers: ['echo'],
tests: [
expect.objectContaining({
vars: expect.objectContaining({ apiKey: '[REDACTED]' }),
}),
],
}),
}),
);
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('sk-test-12345678901234567890');
});
it('restores redacted Azure SAS tokens before rerunning a stored eval', async () => {
const sasUri = 'az://account/container/tests.yaml?sp=r&sig=azure-secret';
mockedEvalFindById.mockResolvedValueOnce({ config: { tests: sasUri } } as never);
await postJob({
...minimalTestSuite,
tests: 'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
sourceEvalId: 'source-eval-id',
});
await vi.waitFor(() => {
expect(mockedEvaluateWithSource).toHaveBeenCalled();
});
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
expect(evaluateArg.tests).toBe(sasUri);
});
it('should not log the raw save body on database failure', async () => {
mockedEvalCreate.mockRejectedValueOnce(new Error('db down'));
const secret = 'sk-test-12345678901234567890';
const response = await api.post('/api/eval').send({
config: {
providers: [{ id: 'openai:gpt-4o', config: { apiKey: secret } }],
},
prompts: [{ raw: 'test prompt', label: 'test prompt' }],
results: [{ promptIdx: 0, testIdx: 0, success: true, score: 1 }],
});
expect(response.status).toBe(500);
expect(errorSpy).toHaveBeenCalledWith(
'Failed to write eval to database',
expect.objectContaining({
body: expect.objectContaining({
config: expect.objectContaining({
providers: [
expect.objectContaining({
config: expect.objectContaining({ apiKey: '[REDACTED]' }),
}),
],
}),
}),
}),
);
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(secret);
});
it('should return false from shouldShareResults when cloud is disabled', async () => {
mockedShouldShareResults.mockReturnValue(false);
await postJob(minimalTestSuite);
await vi.waitFor(() => {
expect(mockedEvaluateWithSource).toHaveBeenCalled();
});
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
expect(evaluateArg.sharing).toBe(false);
});
});
@@ -0,0 +1,92 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getDb } from '../../../src/database/index';
import { runDbMigrations } from '../../../src/migrate';
import EvalResult from '../../../src/models/evalResult';
import { createApp } from '../../../src/server/server';
import { createEvaluateResult } from '../../factories/eval';
import EvalFactory from '../../factories/evalFactory';
describe('Eval Routes - Trace linkage persistence', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await runDbMigrations();
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(async () => {
const db = await getDb();
await db.run('DELETE FROM eval_results');
await db.run('DELETE FROM evals_to_datasets');
await db.run('DELETE FROM evals_to_prompts');
await db.run('DELETE FROM evals_to_tags');
await db.run('DELETE FROM evals');
});
it('keeps trace linkage when v4 eval saves include traced rows', async () => {
const tracedResult = createEvaluateResult({
traceId: 'route-create-trace-id',
evaluationId: 'route-create-evaluation-id',
metadata: { source: 'route-create' },
});
const response = await api.post('/api/eval').send({
config: { description: 'Trace linkage route coverage' },
prompts: [],
results: [tracedResult],
});
expect(response.status).toBe(200);
const [persistedResult] = await EvalResult.findManyByEvalId(response.body.id);
expect(persistedResult.toEvaluateResult()).toMatchObject({
traceId: 'route-create-trace-id',
evaluationId: 'route-create-evaluation-id',
metadata: { source: 'route-create' },
});
});
it('keeps trace linkage when appended results arrive through POST /api/eval/:id/results', async () => {
const eval_ = await EvalFactory.create({ numResults: 0 });
const tracedResult = createEvaluateResult({
traceId: 'route-append-trace-id',
evaluationId: 'route-append-evaluation-id',
metadata: { source: 'route-append' },
});
const response = await api.post(`/api/eval/${eval_.id}/results`).send([
{
id: 'route-append-trace-result',
evalId: eval_.id,
...tracedResult,
},
]);
expect(response.status).toBe(204);
const [persistedResult] = await EvalResult.findManyByEvalId(eval_.id);
expect(persistedResult.toEvaluateResult()).toMatchObject({
traceId: 'route-append-trace-id',
evaluationId: 'route-append-evaluation-id',
metadata: { source: 'route-append' },
});
});
});
+386
View File
@@ -0,0 +1,386 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies BEFORE imports
vi.mock('../../../src/models/eval');
vi.mock('../../../src/globalConfig/accounts');
import Eval, { EvalQueries } from '../../../src/models/eval';
// Import after mocking
import { createApp } from '../../../src/server/server';
import { EVAL_TABLE_MAX_PAGE_SIZE } from '../../../src/types/api/eval';
const mockedEval = vi.mocked(Eval);
const mockedEvalQueries = vi.mocked(EvalQueries);
describe('Eval Routes - Zod Validation', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
let mockFindById: ReturnType<typeof vi.fn>;
let mockSave: ReturnType<typeof vi.fn>;
let mockGetMetadataKeysFromEval: ReturnType<typeof vi.fn>;
let mockGetMetadataValuesFromEval: ReturnType<typeof vi.fn>;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
vi.resetAllMocks();
// Setup mock methods
mockFindById = vi.fn();
mockSave = vi.fn();
mockGetMetadataKeysFromEval = vi.fn();
mockGetMetadataValuesFromEval = vi.fn();
// Mock Eval.findById
mockedEval.findById = mockFindById as any;
// Mock EvalQueries methods
mockedEvalQueries.getMetadataKeysFromEval = mockGetMetadataKeysFromEval as any;
mockedEvalQueries.getMetadataValuesFromEval = mockGetMetadataValuesFromEval as any;
});
afterEach(() => {
vi.resetAllMocks();
});
describe('PATCH /api/eval/:id/author', () => {
it('should return 400 when body is empty', async () => {
const response = await api.patch('/api/eval/test-id/author').send({});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('author');
});
it('should return 400 when author is not a string', async () => {
const response = await api.patch('/api/eval/test-id/author').send({
author: 123,
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('author');
});
it('should return 200 when author is valid', async () => {
const mockEval = {
id: 'test-id',
author: 'old@example.com',
save: mockSave,
};
mockFindById.mockResolvedValue(mockEval);
mockSave.mockResolvedValue(undefined);
const response = await api.patch('/api/eval/test-id/author').send({
author: 'new@example.com',
});
expect(response.status).toBe(200);
expect(response.body.message).toBe('Author updated successfully');
expect(mockEval.author).toBe('new@example.com');
});
});
describe('GET /api/eval/:id/metadata-values', () => {
it('should return 400 when key query param is missing', async () => {
const response = await api.get('/api/eval/test-id/metadata-values');
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('key');
});
it('should return 200 when key query param is provided', async () => {
const mockEval = {
id: 'test-id',
};
mockFindById.mockResolvedValue(mockEval);
mockGetMetadataValuesFromEval.mockReturnValue(['value1', 'value2']);
const response = await api.get('/api/eval/test-id/metadata-values?key=testKey');
expect(response.status).toBe(200);
expect(response.body.values).toEqual(['value1', 'value2']);
});
});
describe('GET /api/eval/:id/metadata-keys', () => {
it('should return 400 when id param is too short', async () => {
const response = await api.get('/api/eval/ab/metadata-keys');
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('id');
});
it('should return 200 with valid request', async () => {
const mockEval = {
id: 'test-id',
};
mockFindById.mockResolvedValue(mockEval);
mockGetMetadataKeysFromEval.mockResolvedValue(['key1', 'key2']);
const response = await api.get('/api/eval/test-id/metadata-keys');
expect(response.status).toBe(200);
expect(response.body.keys).toEqual(['key1', 'key2']);
});
});
describe('GET /api/eval/:id/table', () => {
it.each([
['format', { format: 'xml' }],
['limit', { limit: '1.5' }],
['limit', { limit: String(EVAL_TABLE_MAX_PAGE_SIZE + 1) }],
['offset', { offset: '1.5' }],
])('should return 400 if %s query param is invalid', async (field, query) => {
const response = await api.get('/api/eval/test-id/table').query(query);
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain(field);
expect(mockFindById).not.toHaveBeenCalled();
});
});
describe('POST /api/eval/:id/copy', () => {
it('should return 404 when id param is whitespace (route does not match)', async () => {
const response = await api.post('/api/eval/ /copy').send({});
expect(response.status).toBe(404);
});
it('should return 201 with valid request', async () => {
const mockCopy = vi.fn();
const mockGetResultsCount = vi.fn();
const mockEval = {
id: 'test-id',
copy: mockCopy,
getResultsCount: mockGetResultsCount,
};
const copiedEval = {
id: 'copied-id',
};
mockFindById.mockResolvedValue(mockEval);
mockGetResultsCount.mockResolvedValue(10);
mockCopy.mockResolvedValue(copiedEval);
const response = await api.post('/api/eval/test-id/copy').send({
description: 'Test copy',
});
expect(response.status).toBe(201);
expect(response.body.id).toBe('copied-id');
expect(response.body.distinctTestCount).toBe(10);
});
it('should return 400 when description is not a string', async () => {
const response = await api.post('/api/eval/test-id/copy').send({
description: 123,
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('description');
});
it('should return 201 when description is omitted (optional field)', async () => {
const mockCopy = vi.fn();
const mockGetResultsCount = vi.fn();
const mockEval = {
id: 'test-id',
copy: mockCopy,
getResultsCount: mockGetResultsCount,
};
const copiedEval = {
id: 'copied-id',
};
mockFindById.mockResolvedValue(mockEval);
mockGetResultsCount.mockResolvedValue(5);
mockCopy.mockResolvedValue(copiedEval);
const response = await api.post('/api/eval/test-id/copy').send({});
expect(response.status).toBe(201);
expect(response.body.id).toBe('copied-id');
expect(mockCopy).toHaveBeenCalledWith(undefined, 5);
});
});
describe('PATCH /api/eval/:id', () => {
it('should accept empty body (both fields are optional)', async () => {
// The schema allows empty body since both table and config are optional
const response = await api.patch('/api/eval/test-id').send({});
// This is a valid request - validation passes, but updateResult may fail
// We're only testing validation behavior here
expect(response.status).not.toBe(400);
});
it('should return 400 when table has invalid structure', async () => {
const response = await api.patch('/api/eval/test-id').send({
table: {
head: 'invalid', // Should be an object
},
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 500 when the eval update write fails', async () => {
const mockEval = {
id: 'test-id',
config: {},
save: mockSave,
setTable: vi.fn(),
};
mockFindById.mockResolvedValue(mockEval);
mockSave.mockRejectedValue(new Error('database write failed'));
const response = await api.patch('/api/eval/test-id').send({
config: { description: 'Updated description' },
});
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to update eval table' });
});
});
describe('DELETE /api/eval', () => {
it('should return 400 when ids array is empty', async () => {
const response = await api.delete('/api/eval').send({
ids: [],
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('ids');
});
it('should return 400 when ids is missing', async () => {
const response = await api.delete('/api/eval').send({});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('ids');
});
it('should return 400 when ids contains empty string', async () => {
const response = await api.delete('/api/eval').send({
ids: ['valid-id', ''],
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
});
describe('POST /api/eval/replay', () => {
it('should return 400 when evaluationId is missing', async () => {
const response = await api.post('/api/eval/replay').send({
prompt: 'test prompt',
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('evaluationId');
});
it('should return 400 when prompt is missing', async () => {
const response = await api.post('/api/eval/replay').send({
evaluationId: 'test-id',
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('prompt');
});
it('should return 400 when prompt is empty string', async () => {
const response = await api.post('/api/eval/replay').send({
evaluationId: 'test-id',
prompt: '',
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('prompt');
});
});
describe('POST /api/eval/:id/results', () => {
it('should return 400 when results is not an array', async () => {
const response = await api.post('/api/eval/test-id/results').send({
results: 'not an array',
});
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 400 when result is missing required fields', async () => {
const response = await api.post('/api/eval/test-id/results').send([
{
promptIdx: 0,
// Missing testIdx, success, score
},
]);
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 400 when promptIdx is negative', async () => {
const response = await api.post('/api/eval/test-id/results').send([
{
promptIdx: -1,
testIdx: 0,
success: true,
score: 1,
},
]);
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('promptIdx');
});
});
describe('GET /api/eval/job/:id', () => {
it('should return 400 when id is not a valid UUID', async () => {
const response = await api.get('/api/eval/job/not-a-uuid');
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('id');
});
});
});
+344
View File
@@ -0,0 +1,344 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
import type { MediaStorageProvider } from '../../../src/storage/types';
// Mock dependencies
vi.mock('../../../src/storage');
// Import after mocking
import { getMediaStorage, mediaExists, retrieveMedia } from '../../../src/storage';
const mockedGetMediaStorage = vi.mocked(getMediaStorage);
const mockedMediaExists = vi.mocked(mediaExists);
const mockedRetrieveMedia = vi.mocked(retrieveMedia);
describe('Media Routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('GET /api/media/stats', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should return success with providerId when storage has no stats', async () => {
const mockStorage = {
providerId: 'local-fs',
} as MediaStorageProvider;
mockedGetMediaStorage.mockReturnValue(mockStorage);
const response = await api.get('/api/media/stats');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: {
providerId: 'local-fs',
},
});
});
it('should return success with providerId and stats when storage has getStats', async () => {
const mockStorage = {
providerId: 'local-fs',
getStats: vi.fn().mockResolvedValue({
totalFiles: 42,
totalSize: 1024000,
}),
} as unknown as MediaStorageProvider;
mockedGetMediaStorage.mockReturnValue(mockStorage);
const response = await api.get('/api/media/stats');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: {
providerId: 'local-fs',
totalFiles: 42,
totalSize: 1024000,
},
});
});
it('should return 500 when getMediaStorage throws error', async () => {
mockedGetMediaStorage.mockImplementation(() => {
throw new Error('Storage initialization failed');
});
const response = await api.get('/api/media/stats');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to get storage stats',
});
});
});
describe('GET /api/media/info/:type/:filename', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should return 400 for invalid type', async () => {
const response = await api.get('/api/media/info/document/abcdef123456.pdf');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('type');
});
it('should return 400 when path segments resolve to invalid type and filename', async () => {
// Express resolves ../../ before routing, so params become { type: 'etc', filename: 'passwd' }
const response = await api.get('/api/media/info/audio/../../etc/passwd');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('filename');
});
it('should return 400 for filename without hex prefix', async () => {
const response = await api.get('/api/media/info/audio/malicious.mp3');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('filename');
});
it('should return 400 for filename with wrong hex length', async () => {
const response = await api.get('/api/media/info/audio/abc123.mp3');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('filename');
});
it('should return 404 when media file does not exist', async () => {
mockedMediaExists.mockResolvedValue(false);
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
expect(response.status).toBe(404);
expect(response.body).toEqual({
error: 'Media not found',
});
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
});
it('should return info when media file exists', async () => {
const mockStorage = {
providerId: 'local-fs',
getUrl: vi.fn().mockResolvedValue('/media/audio/abcdef123456.mp3'),
} as unknown as MediaStorageProvider;
mockedMediaExists.mockResolvedValue(true);
mockedGetMediaStorage.mockReturnValue(mockStorage);
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: {
key: 'audio/abcdef123456.mp3',
exists: true,
url: '/media/audio/abcdef123456.mp3',
},
});
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
expect(mockStorage.getUrl).toHaveBeenCalledWith('audio/abcdef123456.mp3');
});
it('should return info when URL generation is unsupported', async () => {
const mockStorage = {
providerId: 'custom-storage',
getUrl: vi.fn().mockResolvedValue(null),
} as unknown as MediaStorageProvider;
mockedMediaExists.mockResolvedValue(true);
mockedGetMediaStorage.mockReturnValue(mockStorage);
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: {
key: 'audio/abcdef123456.mp3',
exists: true,
url: null,
},
});
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
expect(mockStorage.getUrl).toHaveBeenCalledWith('audio/abcdef123456.mp3');
});
it.each(['video', 'image'] as const)('should accept valid %s type', async (type) => {
const ext = type === 'video' ? 'mp4' : 'png';
const mockStorage = {
providerId: 'local-fs',
getUrl: vi.fn().mockResolvedValue(`/media/${type}/abcdef123456.${ext}`),
} as unknown as MediaStorageProvider;
mockedMediaExists.mockResolvedValue(true);
mockedGetMediaStorage.mockReturnValue(mockStorage);
const response = await api.get(`/api/media/info/${type}/abcdef123456.${ext}`);
expect(response.status).toBe(200);
expect(response.body.data.key).toBe(`${type}/abcdef123456.${ext}`);
});
it('should return 500 when mediaExists throws error', async () => {
mockedMediaExists.mockRejectedValue(new Error('Database connection failed'));
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to get media info',
});
});
});
describe('GET /api/media/:type/:filename', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should return 400 for invalid type', async () => {
const response = await api.get('/api/media/text/abcdef123456.txt');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('type');
});
it('should return 400 for invalid filename', async () => {
// Filename must be exactly 12 hex characters + extension
const response = await api.get('/api/media/audio/invalid-name.mp3');
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toContain('filename');
});
it('should return 404 when media not found', async () => {
mockedMediaExists.mockResolvedValue(false);
const response = await api.get('/api/media/audio/abcdef123456.mp3');
expect(response.status).toBe(404);
expect(response.body).toEqual({
error: 'Media not found',
});
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
});
it('should serve audio file with correct headers', async () => {
const mockData = Buffer.from('fake wav data');
mockedMediaExists.mockResolvedValue(true);
mockedRetrieveMedia.mockResolvedValue(mockData);
const response = await api.get('/api/media/audio/abcdef123456.wav');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('audio/wav');
expect(response.headers['content-length']).toBe(String(mockData.length));
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
expect(response.body).toEqual(mockData);
expect(mockedRetrieveMedia).toHaveBeenCalledWith('audio/abcdef123456.wav');
});
it.each([
['audio', 'mp3', 'audio/mpeg'],
['audio', 'ogg', 'audio/ogg'],
['audio', 'webm', 'audio/webm'],
['image', 'png', 'image/png'],
['image', 'jpg', 'image/jpeg'],
['image', 'jpeg', 'image/jpeg'],
['image', 'gif', 'image/gif'],
['image', 'webp', 'image/webp'],
['video', 'mp4', 'video/mp4'],
['video', 'ogv', 'video/ogg'],
])('should serve %s/%s with content-type %s', async (type, ext, expectedContentType) => {
const mockData = Buffer.from(`fake ${ext} data`);
mockedMediaExists.mockResolvedValue(true);
mockedRetrieveMedia.mockResolvedValue(mockData);
const response = await api.get(`/api/media/${type}/abcdef123456.${ext}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe(expectedContentType);
});
it('should use application/octet-stream for unknown extension', async () => {
const mockData = Buffer.from('unknown data');
mockedMediaExists.mockResolvedValue(true);
mockedRetrieveMedia.mockResolvedValue(mockData);
const response = await api.get('/api/media/audio/abcdef123456.xyz');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toBe('application/octet-stream');
});
it('should return 500 when retrieveMedia throws error', async () => {
mockedMediaExists.mockResolvedValue(true);
mockedRetrieveMedia.mockRejectedValue(new Error('Disk read error'));
const response = await api.get('/api/media/audio/abcdef123456.mp3');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to serve media',
});
});
it.each([
['ABCDEF123456', 'uppercase'],
['AbCdEf123456', 'mixed case'],
])('should accept %s hex characters in filename (%s)', async (hex) => {
const mockData = Buffer.from('fake data');
mockedMediaExists.mockResolvedValue(true);
mockedRetrieveMedia.mockResolvedValue(mockData);
const response = await api.get(`/api/media/audio/${hex}.mp3`);
expect(response.status).toBe(200);
expect(mockedRetrieveMedia).toHaveBeenCalledWith(`audio/${hex}.mp3`);
});
});
});
File diff suppressed because it is too large Load Diff
+716
View File
@@ -0,0 +1,716 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
import type { ProviderTestResult } from '../../../src/node/testProvider';
import type { ApiProvider, ProviderOptions } from '../../../src/types/providers';
// Mock dependencies
vi.mock('../../../src/providers/index');
vi.mock('../../../src/node/testProvider');
vi.mock('../../../src/server/config/serverConfig');
vi.mock('../../../src/redteam/commands/discover');
vi.mock('../../../src/redteam/remoteGeneration');
vi.mock('../../../src/util/fetch/index');
vi.mock('../../../src/globalConfig/cloud', () => ({
cloudConfig: {
isEnabled: vi.fn(),
getApiHost: vi.fn(),
getApiKey: vi.fn(),
},
}));
// Import after mocking
import { cloudConfig } from '../../../src/globalConfig/cloud';
import { testProviderConnectivity, testProviderSession } from '../../../src/node/testProvider';
import { loadApiProvider } from '../../../src/providers/index';
import { doTargetPurposeDiscovery } from '../../../src/redteam/commands/discover';
import { neverGenerateRemote } from '../../../src/redteam/remoteGeneration';
import { getAvailableProviders } from '../../../src/server/config/serverConfig';
import { fetchWithProxy } from '../../../src/util/fetch/index';
const mockedLoadApiProvider = vi.mocked(loadApiProvider);
const mockedDoTargetPurposeDiscovery = vi.mocked(doTargetPurposeDiscovery);
const mockedNeverGenerateRemote = vi.mocked(neverGenerateRemote);
const mockedTestProviderConnectivity = vi.mocked(testProviderConnectivity);
const mockedGetAvailableProviders = vi.mocked(getAvailableProviders);
const mockedTestProviderSession = vi.mocked(testProviderSession);
const mockedFetchWithProxy = vi.mocked(fetchWithProxy);
describe('Providers Routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
vi.resetAllMocks();
// Default to the non-cloud path so host-dependent assertions are deterministic
// regardless of the machine's cloud-login state.
vi.mocked(cloudConfig.isEnabled).mockReturnValue(false);
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.promptfoo.app');
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('GET /providers/config-status', () => {
it('should return hasCustomConfig: false when no custom config exists', async () => {
// getAvailableProviders returns empty array when no config
mockedGetAvailableProviders.mockReturnValue([]);
const response = await api.get('/api/providers/config-status');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: { hasCustomConfig: false },
});
});
it('should return hasCustomConfig: true when custom config exists', async () => {
const customProviders = [
{ id: 'openai:gpt-4o-mini' },
{ id: 'anthropic:messages:claude-haiku-4-5-20251001' },
];
mockedGetAvailableProviders.mockReturnValue(customProviders);
const response = await api.get('/api/providers/config-status');
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
data: { hasCustomConfig: true },
});
});
it('should return standardized 500 errors when config status loading fails', async () => {
mockedGetAvailableProviders.mockImplementation(() => {
throw new Error('config unavailable');
});
const response = await api.get('/api/providers/config-status');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to load provider config status' });
});
});
describe('POST /providers/test', () => {
let mockProvider: ApiProvider;
beforeEach(() => {
// Setup mock provider
mockProvider = {
id: vi.fn(() => 'test-provider'),
callApi: vi.fn(),
config: {},
} as any;
// Default mock implementations
mockedLoadApiProvider.mockResolvedValue(mockProvider);
});
it('should handle valid request with prompt', async () => {
const testPrompt = 'Test prompt';
const providerOptions: ProviderOptions = {
id: 'http://example.com/api',
config: {
method: 'POST',
},
};
const mockResult: ProviderTestResult = {
success: true,
message: 'Provider test successful',
providerResponse: { output: 'Test response' },
transformedRequest: { url: 'http://example.com/api' },
};
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
const response = await api.post('/api/providers/test').send({
prompt: testPrompt,
providerOptions,
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
testResult: {
success: true,
message: 'Provider test successful',
error: undefined,
changes_needed: undefined,
changes_needed_reason: undefined,
changes_needed_suggestions: undefined,
},
providerResponse: { output: 'Test response' },
transformedRequest: { url: 'http://example.com/api' },
});
expect(mockedLoadApiProvider).toHaveBeenCalledWith('http://example.com/api', {
options: {
...providerOptions,
config: {
...providerOptions.config,
maxRetries: 1,
},
},
});
expect(mockedTestProviderConnectivity).toHaveBeenCalledWith({
provider: mockProvider,
prompt: testPrompt,
inputs: undefined,
});
});
it('should handle valid request without prompt (optional)', async () => {
const providerOptions: ProviderOptions = {
id: 'http://example.com/api',
config: {},
};
const mockResult: ProviderTestResult = {
success: true,
message: 'Provider test successful',
};
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
const response = await api.post('/api/providers/test').send({
providerOptions,
});
expect(response.status).toBe(200);
expect(mockedTestProviderConnectivity).toHaveBeenCalledWith({
provider: mockProvider,
prompt: undefined,
inputs: undefined,
});
});
it('should return 400 for missing providerOptions', async () => {
const response = await api.post('/api/providers/test').send({
prompt: 'Test prompt',
});
expect(response.status).toBe(400);
expect(response.body).toEqual(
expect.objectContaining({
error: expect.stringContaining('providerOptions'),
}),
);
});
it('should return 400 for missing provider id', async () => {
const response = await api.post('/api/providers/test').send({
providerOptions: { config: {} },
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when provider id is a number', async () => {
const response = await api.post('/api/providers/test').send({
providerOptions: { id: 123 },
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when provider id is an object', async () => {
const response = await api.post('/api/providers/test').send({
providerOptions: { id: {} },
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when provider id is empty string', async () => {
const response = await api.post('/api/providers/test').send({
providerOptions: { id: '' },
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should handle provider loading failure', async () => {
const providerOptions: ProviderOptions = {
id: 'invalid-provider',
config: {},
};
mockedLoadApiProvider.mockRejectedValue(new Error('Failed to load provider'));
const response = await api.post('/api/providers/test').send({
providerOptions,
});
// The route should catch the error and return 500
expect(response.status).toBe(500);
});
it('should handle connectivity test failure', async () => {
const providerOptions: ProviderOptions = {
id: 'http://example.com/api',
config: {},
};
const mockResult: ProviderTestResult = {
success: false,
message: 'Connection failed',
error: 'Network timeout',
};
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
const response = await api.post('/api/providers/test').send({
providerOptions,
prompt: 'Test',
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
testResult: {
success: false,
message: 'Connection failed',
error: 'Network timeout',
changes_needed: undefined,
changes_needed_reason: undefined,
changes_needed_suggestions: undefined,
},
providerResponse: undefined,
transformedRequest: undefined,
});
});
it('should handle successful test with analysis and suggestions', async () => {
const providerOptions: ProviderOptions = {
id: 'http://example.com/api',
config: {},
};
const mockResult: ProviderTestResult = {
success: true,
message: 'Test completed with suggestions',
providerResponse: { output: 'Response' },
analysis: {
changes_needed: true,
changes_needed_reason: 'Response format is not optimal',
changes_needed_suggestions: [
'Add response transform to extract text field',
'Update headers to include authentication',
],
},
};
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
const response = await api.post('/api/providers/test').send({
providerOptions,
prompt: 'Test',
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
testResult: {
success: true,
message: 'Test completed with suggestions',
error: undefined,
changes_needed: true,
changes_needed_reason: 'Response format is not optimal',
changes_needed_suggestions: [
'Add response transform to extract text field',
'Update headers to include authentication',
],
},
providerResponse: { output: 'Response' },
transformedRequest: undefined,
});
});
it('should properly structure response with all fields', async () => {
const providerOptions: ProviderOptions = {
id: 'http://example.com/api',
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
};
const mockResult: ProviderTestResult = {
success: true,
message: 'All systems operational',
error: undefined,
providerResponse: {
output: 'AI response text',
metadata: { latency: 150 },
},
transformedRequest: {
url: 'http://example.com/api',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: 'Comprehensive test' },
},
analysis: {
changes_needed: false,
},
};
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
const response = await api.post('/api/providers/test').send({
providerOptions,
prompt: 'Comprehensive test',
});
expect(response.status).toBe(200);
// Verify testResult structure
expect(response.body.testResult.success).toBe(true);
expect(response.body.testResult.message).toBe('All systems operational');
expect(response.body.testResult.error).toBeUndefined();
expect(response.body.testResult.changes_needed).toBe(false);
expect(response.body.testResult.changes_needed_reason).toBeUndefined();
expect(response.body.testResult.changes_needed_suggestions).toBeUndefined();
// Verify providerResponse
expect(response.body.providerResponse).toEqual({
output: 'AI response text',
metadata: { latency: 150 },
});
// Verify transformedRequest
expect(response.body.transformedRequest).toEqual({
url: 'http://example.com/api',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { prompt: 'Comprehensive test' },
});
});
it('should pass maxRetries: 1 to provider config', async () => {
const providerOptions: ProviderOptions = {
id: 'http://example.com/api',
config: {
maxRetries: 5, // Should be overridden to 1
timeout: 30000,
},
};
const mockResult: ProviderTestResult = {
success: true,
message: 'Success',
};
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
const response = await api.post('/api/providers/test').send({
providerOptions,
});
expect(response.status).toBe(200);
expect(mockedLoadApiProvider).toHaveBeenCalledWith('http://example.com/api', {
options: {
...providerOptions,
config: {
maxRetries: 1, // Should be 1, not 5
timeout: 30000,
},
},
});
});
});
describe('POST /providers/http-generator validation', () => {
it('should return generated HTTP configuration objects from the cloud API', async () => {
const generatedConfig = {
url: 'https://api.example.com/v1/chat',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
mockedFetchWithProxy.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(generatedConfig),
} as any);
const response = await api
.post('/api/providers/http-generator')
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
expect(response.status).toBe(200);
expect(response.body).toEqual(generatedConfig);
expect(mockedFetchWithProxy).toHaveBeenCalledWith(
'https://api.promptfoo.app/api/v1/http-provider-generator',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
requestExample: 'curl https://api.example.com/v1/chat',
responseExample: undefined,
}),
}),
);
});
it('should call the configured on-prem cloud host with a bearer token when cloud is enabled', async () => {
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://onprem.example.com/');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-onprem-key');
const generatedConfig = {
url: 'https://api.example.com/v1/chat',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
mockedFetchWithProxy.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(generatedConfig),
} as any);
const response = await api
.post('/api/providers/http-generator')
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
expect(response.status).toBe(200);
const [calledUrl, calledOpts] = mockedFetchWithProxy.mock.calls[0];
// Trailing slash on the configured host is normalized (no //api/v1).
expect(calledUrl).toBe('https://onprem.example.com/api/v1/http-provider-generator');
expect(calledUrl).not.toContain('api.promptfoo.app');
expect((calledOpts?.headers as Record<string, string>)?.Authorization).toBe(
'Bearer test-onprem-key',
);
});
it('should not send an Authorization header when cloud is not enabled', async () => {
// default beforeEach: isEnabled=false
mockedFetchWithProxy.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ url: 'https://x', method: 'POST', headers: {} }),
} as any);
const response = await api
.post('/api/providers/http-generator')
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
expect(response.status).toBe(200);
const [calledUrl, calledOpts] = mockedFetchWithProxy.mock.calls[0];
expect(calledUrl).toBe('https://api.promptfoo.app/api/v1/http-provider-generator');
expect((calledOpts?.headers as Record<string, string>)?.Authorization).toBeUndefined();
});
it('should not call the hosted HTTP generator when remote generation is disabled', async () => {
mockedNeverGenerateRemote.mockReturnValue(true);
const response = await api
.post('/api/providers/http-generator')
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Requires remote generation be enabled.' });
expect(mockedFetchWithProxy).not.toHaveBeenCalled();
});
it('should return 500 when the cloud API returns a non-object generator response', async () => {
mockedFetchWithProxy.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(['not', 'a', 'config']),
} as any);
const response = await api
.post('/api/providers/http-generator')
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to generate HTTP configuration' });
});
it('should return 400 for empty body', async () => {
const response = await api.post('/api/providers/http-generator').send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for empty string requestExample', async () => {
const response = await api.post('/api/providers/http-generator').send({ requestExample: '' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for non-string requestExample', async () => {
const response = await api
.post('/api/providers/http-generator')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ requestExample: 123 }));
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
});
describe('POST /providers/test-session validation', () => {
it('should return 400 for empty body', async () => {
const response = await api.post('/api/providers/test-session').send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for missing provider', async () => {
const response = await api
.post('/api/providers/test-session')
.send({ sessionConfig: {}, mainInputVariable: 'input' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when provider is not an object', async () => {
const response = await api
.post('/api/providers/test-session')
.send({ provider: 'not-an-object' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when provider.id is a number', async () => {
const response = await api
.post('/api/providers/test-session')
.send({ provider: { id: 123 } });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when provider.id is an object', async () => {
const response = await api.post('/api/providers/test-session').send({ provider: { id: {} } });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should accept valid minimal body', async () => {
const mockProvider = {
id: vi.fn(() => 'test-provider'),
callApi: vi.fn(),
config: {},
} as any;
mockedLoadApiProvider.mockResolvedValue(mockProvider);
mockedTestProviderSession.mockResolvedValue({
success: true,
message: 'Session test successful',
} as any);
const response = await api
.post('/api/providers/test-session')
.send({ provider: { id: 'http://example.com/api' } });
expect(response.status).toBe(200);
});
it('should return standardized 500 errors when session provider loading fails', async () => {
mockedLoadApiProvider.mockRejectedValue(new Error('provider unavailable'));
const response = await api
.post('/api/providers/test-session')
.send({ provider: { id: 'http://example.com/api' } });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to test session' });
});
});
describe('POST /providers/discover validation', () => {
it('should return discovered target metadata for valid provider options', async () => {
const mockProvider = {
id: vi.fn(() => 'test-provider'),
callApi: vi.fn(),
config: {},
} as any;
const discoveryResult = {
purpose: 'Help users',
limitations: 'No medical advice',
user: 'Support agents',
tools: [
{
name: 'lookup',
description: 'Fetch account details',
arguments: [{ name: 'id', description: 'Account id', type: 'string' }],
},
],
};
mockedNeverGenerateRemote.mockReturnValue(false);
mockedLoadApiProvider.mockResolvedValue(mockProvider);
mockedDoTargetPurposeDiscovery.mockResolvedValue(discoveryResult);
const response = await api.post('/api/providers/discover').send({ id: 'echo' });
expect(response.status).toBe(200);
expect(response.body).toEqual(discoveryResult);
});
it('should return 400 for empty body (missing id)', async () => {
const response = await api.post('/api/providers/discover').send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when id is a number', async () => {
const response = await api.post('/api/providers/discover').send({ id: 123 });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when id is an object', async () => {
const response = await api.post('/api/providers/discover').send({ id: {} });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for non-object body', async () => {
const response = await api
.post('/api/providers/discover')
.send('not-an-object')
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
});
});
});
+877
View File
@@ -0,0 +1,877 @@
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
// Mock dependencies
vi.mock('../../../src/redteam/plugins/index');
vi.mock('../../../src/redteam/providers/shared');
vi.mock('../../../src/redteam/shared');
vi.mock('../../../src/redteam/remoteGeneration');
vi.mock('../../../src/util/fetch/index');
vi.mock('../../../src/server/services/redteamTestCaseGenerationService');
// Import after mocking
import logger from '../../../src/logger';
import { Plugins } from '../../../src/redteam/plugins/index';
import { redteamProviderManager } from '../../../src/redteam/providers/shared';
import { getRemoteGenerationUrl, neverGenerateRemote } from '../../../src/redteam/remoteGeneration';
import { doRedteamRun } from '../../../src/redteam/shared';
import {
extractGeneratedPrompt,
getPluginConfigurationError,
} from '../../../src/server/services/redteamTestCaseGenerationService';
import { fetchWithProxy } from '../../../src/util/fetch/index';
const mockedPlugins = vi.mocked(Plugins);
const mockedRedteamProviderManager = vi.mocked(redteamProviderManager);
const mockedGetPluginConfigurationError = vi.mocked(getPluginConfigurationError);
const mockedExtractGeneratedPrompt = vi.mocked(extractGeneratedPrompt);
const mockedDoRedteamRun = vi.mocked(doRedteamRun);
const mockedGetRemoteGenerationUrl = vi.mocked(getRemoteGenerationUrl);
const mockedNeverGenerateRemote = vi.mocked(neverGenerateRemote);
const mockedFetchWithProxy = vi.mocked(fetchWithProxy);
const debugSpy = vi.spyOn(logger, 'debug');
describe('Redteam Routes', () => {
let app: ReturnType<typeof createApp>;
beforeEach(() => {
app = createApp();
});
describe('POST /redteam/generate-test', () => {
beforeEach(() => {
vi.resetAllMocks();
// Default mock implementations
mockedGetPluginConfigurationError.mockReturnValue(null);
mockedRedteamProviderManager.getProvider.mockResolvedValue({
id: () => 'test-provider',
callApi: vi.fn(),
} as any);
mockedExtractGeneratedPrompt.mockReturnValue('generated test prompt');
});
describe('excluded plugins logic', () => {
it('should NOT exclude dataset-exempt plugins without multi-input config', async () => {
// 'aegis' is a DATASET_EXEMPT_PLUGIN but should work without multi-input
const mockPluginFactory = {
key: 'aegis',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'aegis',
config: {},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
// Should have called the plugin factory action (not excluded)
expect(mockPluginFactory.action).toHaveBeenCalled();
expect(response.body.prompt).toBe('generated test prompt');
});
it('should default missing application purpose for generated tests', async () => {
const mockPluginFactory = {
key: 'aegis',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'aegis',
config: {},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {},
},
});
expect(response.status).toBe(200);
expect(mockPluginFactory.action).toHaveBeenCalledWith(
expect.objectContaining({ purpose: 'general AI assistant' }),
);
expect(response.body.prompt).toBe('generated test prompt');
});
it('should exclude dataset-exempt plugins with multi-input config', async () => {
// 'beavertails' is a DATASET_EXEMPT_PLUGIN - should be excluded with inputs
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'beavertails',
config: {
inputs: { query: 'user query', context: 'additional context' },
},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(response.body).toEqual({ testCases: [], count: 0 });
});
it('should exclude multi-input excluded plugins when plugin has multi-input config', async () => {
// 'cca' is a MULTI_INPUT_EXCLUDED_PLUGIN - should be excluded only with multi-input
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'cca',
config: {
inputs: { query: 'user query', context: 'additional context' },
},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(response.body).toEqual({ testCases: [], count: 0 });
});
it('should NOT exclude multi-input excluded plugins when plugin has no multi-input config', async () => {
// 'cca' is a MULTI_INPUT_EXCLUDED_PLUGIN - should NOT be excluded without multi-input
const mockPluginFactory = {
key: 'cca',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'cca',
config: {}, // No inputs - should not be excluded
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
// Should have called the plugin factory action (not returned early)
expect(mockPluginFactory.action).toHaveBeenCalled();
// Single test case returns 'prompt' instead of 'testCases' array
expect(response.body.prompt).toBe('generated test prompt');
});
it('should NOT exclude multi-input excluded plugins when inputs is empty object', async () => {
// Empty inputs object should not trigger multi-input exclusion
const mockPluginFactory = {
key: 'cross-session-leak',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'cross-session-leak',
config: {
inputs: {}, // Empty inputs - should not be excluded
},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
// Should have called the plugin factory action (not returned early)
expect(mockPluginFactory.action).toHaveBeenCalled();
});
it('should not exclude system-prompt-override with multi-input config', async () => {
const mockPluginFactory = {
key: 'system-prompt-override',
action: vi.fn().mockResolvedValue([{ vars: { __prompt: 'test' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'system-prompt-override',
config: {
inputs: { systemPrompt: 'system', userInput: 'user' },
},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(mockPluginFactory.action).toHaveBeenCalled();
expect(response.body.prompt).toBe('generated test prompt');
});
it('should NOT exclude special-token-injection without multi-input config', async () => {
const mockPluginFactory = {
key: 'special-token-injection',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'special-token-injection',
config: {}, // No inputs
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(mockPluginFactory.action).toHaveBeenCalled();
});
it('should process regular plugins normally without inputs', async () => {
// 'harmful:hate' is a regular plugin, not in any exclusion list
const mockPluginFactory = {
key: 'harmful:hate',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test case' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'harmful:hate',
config: {},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(mockPluginFactory.action).toHaveBeenCalled();
// Single test case returns 'prompt' instead of 'testCases' array
expect(response.body.prompt).toBe('generated test prompt');
});
it('should process regular plugins normally with multi-input config', async () => {
// Regular plugins with inputs should still be processed
const mockPluginFactory = {
key: 'harmful:hate',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test case' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'harmful:hate',
config: {
inputs: { query: 'user input', context: 'context' },
},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(mockPluginFactory.action).toHaveBeenCalled();
expect(response.body.prompt).toBe('generated test prompt');
});
it('should preserve HarmBench category filters when generating preview tests', async () => {
const mockPluginFactory = {
key: 'harmbench',
action: vi.fn().mockResolvedValue([{ vars: { query: 'test case' } }]),
};
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'harmbench',
config: {
categories: ['misinformation'],
functionalCategories: ['contextual'],
},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(200);
expect(mockPluginFactory.action).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
categories: ['misinformation'],
functionalCategories: ['contextual'],
language: 'en',
__nonce: expect.any(Number),
}),
}),
);
expect(response.body.prompt).toBe('generated test prompt');
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('validation', () => {
it('should return 400 for invalid plugin ID', async () => {
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'invalid-plugin-id',
config: {},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('Invalid plugin ID');
});
it('should return 400 for invalid strategy ID', async () => {
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'harmful:hate',
config: {},
},
strategy: {
id: 'invalid-strategy',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('Invalid strategy ID');
});
it('should return 400 for plugin configuration error', async () => {
mockedGetPluginConfigurationError.mockReturnValue(
'Plugin requires additional configuration',
);
const response = await request(app)
.post('/api/redteam/generate-test')
.send({
plugin: {
id: 'harmful:hate',
config: {},
},
strategy: {
id: 'basic',
config: {},
},
config: {
applicationDefinition: {
purpose: 'test assistant',
},
},
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Plugin requires additional configuration');
});
});
});
describe('POST /redteam/run', () => {
beforeEach(() => {
vi.resetAllMocks();
mockedDoRedteamRun.mockResolvedValue(undefined as any);
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return job id for valid request', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({
config: { purpose: 'test' },
force: true,
verbose: false,
delay: 0,
maxConcurrency: 2,
});
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
expect(typeof response.body.id).toBe('string');
expect(mockedDoRedteamRun).toHaveBeenCalled();
});
it('should publish a completed redteam eval through the eval job endpoint', async () => {
const summary = { results: [] };
mockedDoRedteamRun.mockResolvedValueOnce({
id: 'redteam-eval-id',
toEvaluateSummary: vi.fn().mockResolvedValue(summary),
} as any);
const runResponse = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' } });
expect(runResponse.status).toBe(200);
await vi.waitFor(async () => {
const completedResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
expect(completedResponse.body).toMatchObject({
status: 'complete',
evalId: 'redteam-eval-id',
result: summary,
});
});
});
it('should publish logs and cancellation through the eval job endpoint', async () => {
let resolveRun: ((value: undefined) => void) | undefined;
mockedDoRedteamRun.mockReturnValueOnce(
new Promise((resolve) => {
resolveRun = resolve;
}),
);
const runResponse = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' } });
expect(runResponse.status).toBe(200);
const runArgs = mockedDoRedteamRun.mock.calls[0][0];
runArgs.logCallback?.('working');
const inProgressResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
expect(inProgressResponse.body).toMatchObject({
status: 'in-progress',
logs: ['working'],
});
const cancelResponse = await request(app).post('/api/redteam/cancel');
expect(cancelResponse.status).toBe(200);
const cancelledResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
expect(cancelledResponse.body).toMatchObject({
status: 'error',
logs: ['working', 'Job cancelled by user'],
});
resolveRun!(undefined);
await vi.waitFor(async () => {
const settledResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
expect(settledResponse.body).toMatchObject({
status: 'error',
logs: ['working', 'Job cancelled by user'],
});
});
});
it('should keep a replaced job cancelled when its stale run settles', async () => {
let resolveFirstRun: ((value: any) => void) | undefined;
let resolveSecondRun: ((value: undefined) => void) | undefined;
mockedDoRedteamRun
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstRun = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondRun = resolve;
}),
);
const firstResponse = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'first' } });
const secondResponse = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'second' } });
expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);
const cancelledResponse = await request(app).get(`/api/eval/job/${firstResponse.body.id}`);
expect(cancelledResponse.body).toMatchObject({
status: 'error',
logs: ['Job cancelled - new job started'],
});
const toEvaluateSummary = vi.fn().mockResolvedValue({ results: [] });
resolveFirstRun!({
id: 'stale-redteam-eval-id',
toEvaluateSummary,
});
await vi.waitFor(async () => {
const settledResponse = await request(app).get(`/api/eval/job/${firstResponse.body.id}`);
expect(settledResponse.body).toMatchObject({
status: 'error',
logs: ['Job cancelled - new job started'],
});
});
expect(toEvaluateSummary).toHaveBeenCalledOnce();
resolveSecondRun!(undefined);
await vi.waitFor(async () => {
const statusResponse = await request(app).get('/api/redteam/status');
expect(statusResponse.body).toMatchObject({
hasRunningJob: false,
jobId: null,
});
});
});
it('should preserve streamed logs when the background run rejects', async () => {
mockedDoRedteamRun.mockImplementationOnce(async ({ logCallback }) => {
logCallback?.('working');
throw new Error('run failed');
});
const runResponse = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' } });
expect(runResponse.status).toBe(200);
await vi.waitFor(async () => {
const failedResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
expect(failedResponse.body).toMatchObject({
status: 'error',
logs: expect.arrayContaining(['working', 'Error: run failed']),
});
});
});
it('should not force runtime defaults when delay and maxConcurrency are omitted', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({
config: { purpose: 'test' },
});
expect(response.status).toBe(200);
const runArgs = mockedDoRedteamRun.mock.calls[0][0];
expect(runArgs.liveRedteamConfig).toEqual({ purpose: 'test' });
expect(runArgs).not.toHaveProperty('delay');
expect(runArgs).not.toHaveProperty('maxConcurrency');
});
it('should return 400 when config is missing', async () => {
const response = await request(app).post('/api/redteam/run').send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when config is not an object', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({ config: 'not-an-object' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when force is not a boolean', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' }, force: 'yes' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should accept string delay and maxConcurrency', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({
config: { purpose: 'test' },
delay: '100',
maxConcurrency: '4',
});
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
});
it('should return 400 when delay is a non-numeric string', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' }, delay: 'abc' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when maxConcurrency is a non-numeric string', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' }, maxConcurrency: 'abc' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when maxConcurrency is less than 1', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' }, maxConcurrency: 0 });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 when delay is negative', async () => {
const response = await request(app)
.post('/api/redteam/run')
.send({ config: { purpose: 'test' }, delay: -1 });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
});
describe('POST /redteam/:taskId', () => {
beforeEach(() => {
vi.resetAllMocks();
debugSpy.mockClear();
mockedGetRemoteGenerationUrl.mockReturnValue('https://api.example.com/task');
});
afterEach(() => {
vi.resetAllMocks();
});
it('should proxy valid request to cloud', async () => {
mockedFetchWithProxy.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'success' }),
} as any);
const response = await request(app).post('/api/redteam/my-task').send({ data: 'test' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ result: 'success' });
expect(mockedFetchWithProxy).toHaveBeenCalledWith(
'https://api.example.com/task',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ data: 'test', task: 'my-task' }),
}),
);
});
it('should not proxy task bodies when remote generation is disabled', async () => {
mockedNeverGenerateRemote.mockReturnValue(true);
const response = await request(app).post('/api/redteam/my-task').send({ data: 'test' });
expect(response.status).toBe(400);
expect(response.body).toEqual({
success: false,
error: 'Requires remote generation be enabled.',
});
expect(mockedGetRemoteGenerationUrl).not.toHaveBeenCalled();
expect(mockedFetchWithProxy).not.toHaveBeenCalled();
});
it('should log task metadata without stringifying the body', async () => {
mockedFetchWithProxy.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'success' }),
} as any);
const response = await request(app).post('/api/redteam/my-task').send({
data: 'test',
secret: 'value',
});
expect(response.status).toBe(200);
expect(debugSpy).toHaveBeenCalledWith(
'Received my-task task request',
expect.objectContaining({
method: 'POST',
url: '/my-task',
body: expect.objectContaining({
data: 'test',
secret: '[REDACTED]',
}),
}),
);
});
it('should return 500 when cloud function fails', async () => {
mockedFetchWithProxy.mockResolvedValue({
ok: false,
status: 503,
} as any);
const response = await request(app).post('/api/redteam/my-task').send({ data: 'test' });
expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to process my-task task');
});
it('should return 400 when taskId exceeds max length', async () => {
const longTaskId = 'a'.repeat(129);
const response = await request(app).post(`/api/redteam/${longTaskId}`).send({ data: 'test' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should accept body with various shapes', async () => {
mockedFetchWithProxy.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ ok: true }),
} as any);
const response = await request(app)
.post('/api/redteam/some-task')
.send({ nested: { deep: true }, list: [1, 2, 3] });
expect(response.status).toBe(200);
});
});
describe('POST /redteam/cancel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return 400 when no job is running', async () => {
const response = await request(app).post('/api/redteam/cancel');
expect(response.status).toBe(400);
expect(response.body.error).toBe('No job currently running');
});
});
describe('GET /redteam/status', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return status shape', async () => {
const response = await request(app).get('/api/redteam/status');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('hasRunningJob');
expect(response.body).toHaveProperty('jobId');
expect(response.body.hasRunningJob).toBe(false);
expect(response.body.jobId).toBeNull();
});
});
});
+562
View File
@@ -0,0 +1,562 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
import { promptCacheService } from '../../../src/server/services/promptCacheService';
vi.mock('../../../src/globalConfig/cloud', () => ({
cloudConfig: {
isEnabled: vi.fn(),
getApiHost: vi.fn(),
getAppUrl: vi.fn(),
validateAndSetApiToken: vi.fn(),
delete: vi.fn(),
},
}));
vi.mock('../../../src/logger', () => ({
__esModule: true,
default: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('../../../src/models/eval', () => ({
default: {
findById: vi.fn(),
},
getEvalSummaries: vi.fn(),
}));
vi.mock('../../../src/redteam/remoteGeneration', () => ({
getRemoteHealthUrl: vi.fn(),
}));
vi.mock('../../../src/share', () => ({
createShareableUrl: vi.fn(),
determineShareDomain: vi.fn(),
stripAuthFromUrl: vi.fn((url: string) => url),
}));
vi.mock('../../../src/telemetry', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/telemetry')>();
return {
...actual,
default: {
record: vi.fn(),
},
};
});
vi.mock('../../../src/testCase/synthesis', () => ({
synthesizeFromTestSuite: vi.fn(),
}));
vi.mock('../../../src/util/apiHealth', () => ({
checkRemoteHealth: vi.fn(),
}));
vi.mock('../../../src/util/database', () => ({
getPrompts: vi.fn(),
getPromptsForTestCasesHash: vi.fn(),
getStandaloneEvals: vi.fn(),
getTestCases: vi.fn(),
readResult: vi.fn(),
}));
import { cloudConfig } from '../../../src/globalConfig/cloud';
import Eval, { getEvalSummaries } from '../../../src/models/eval';
import { getRemoteHealthUrl } from '../../../src/redteam/remoteGeneration';
import { createShareableUrl, determineShareDomain } from '../../../src/share';
import telemetry from '../../../src/telemetry';
import { synthesizeFromTestSuite } from '../../../src/testCase/synthesis';
import { checkRemoteHealth } from '../../../src/util/apiHealth';
import {
getPrompts,
getPromptsForTestCasesHash,
getStandaloneEvals,
getTestCases,
readResult,
} from '../../../src/util/database';
const mockedCloudConfig = vi.mocked(cloudConfig);
const mockedEval = vi.mocked(Eval);
const mockedGetEvalSummaries = vi.mocked(getEvalSummaries);
const mockedGetRemoteHealthUrl = vi.mocked(getRemoteHealthUrl);
const mockedCreateShareableUrl = vi.mocked(createShareableUrl);
const mockedDetermineShareDomain = vi.mocked(determineShareDomain);
const mockedTelemetry = vi.mocked(telemetry);
const mockedSynthesizeFromTestSuite = vi.mocked(synthesizeFromTestSuite);
const mockedCheckRemoteHealth = vi.mocked(checkRemoteHealth);
const mockedGetPrompts = vi.mocked(getPrompts);
const mockedGetPromptsForTestCasesHash = vi.mocked(getPromptsForTestCasesHash);
const mockedGetStandaloneEvals = vi.mocked(getStandaloneEvals);
const mockedGetTestCases = vi.mocked(getTestCases);
const mockedReadResult = vi.mocked(readResult);
describe('inline server API DTO validation', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
vi.resetAllMocks();
// promptCacheService is a module-level singleton; clear its cache so a prompt list cached
// by one test cannot leak into another.
promptCacheService.invalidate();
});
afterEach(() => {
promptCacheService.invalidate();
vi.resetAllMocks();
});
it('validates and parses remote health responses', async () => {
mockedGetRemoteHealthUrl.mockReturnValue('https://api.example.test/health');
mockedCheckRemoteHealth.mockResolvedValue({ status: 'OK', message: 'healthy' });
const response = await api.get('/api/remote-health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'OK', message: 'healthy' });
});
it('returns the disabled remote health DTO when remote generation is disabled', async () => {
mockedGetRemoteHealthUrl.mockReturnValue(null);
const response = await api.get('/api/remote-health');
expect(response.status).toBe(200);
expect(response.body).toEqual({
status: 'DISABLED',
message: 'remote generation and grading are disabled',
});
expect(mockedCheckRemoteHealth).not.toHaveBeenCalled();
});
it('validates /api/results query params before loading summaries', async () => {
mockedGetEvalSummaries.mockResolvedValue([
{
evalId: 'eval-1',
datasetId: 'dataset-1',
createdAt: 1,
description: 'Redteam report',
numTests: 2,
} as never,
]);
const response = await api.get('/api/results?type=redteam&includeProviders=true');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(mockedGetEvalSummaries).toHaveBeenCalledWith(undefined, 'redteam', true);
});
it('rejects invalid /api/results query params', async () => {
const response = await api.get('/api/results?type=sideways');
expect(response.status).toBe(400);
expect(response.body.error).toContain('type');
expect(mockedGetEvalSummaries).not.toHaveBeenCalled();
});
it('returns JSON error DTOs for missing result files', async () => {
mockedReadResult.mockResolvedValue(undefined);
const response = await api.get('/api/results/missing-eval');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Result not found' });
});
it('redacts Azure Blob SAS tokens from result and dataset response DTOs', async () => {
const sasUri = 'az://account/container/tests.yaml?sp=r&sig=azure-secret';
mockedReadResult.mockResolvedValue({
id: 'eval-1',
createdAt: new Date(),
result: { config: { tests: sasUri } },
} as never);
mockedGetTestCases.mockResolvedValue([{ testCases: sasUri }] as never);
const resultResponse = await api.get('/api/results/eval-1');
const datasetsResponse = await api.get('/api/datasets');
expect(resultResponse.status).toBe(200);
expect(resultResponse.body.data.config.tests).toBe(
'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
);
expect(datasetsResponse.status).toBe(200);
expect(datasetsResponse.body.data[0].testCases).toBe(
'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
);
});
it('validates prompt hash params and returns prompt DTOs', async () => {
const hash = 'a'.repeat(64);
mockedGetPromptsForTestCasesHash.mockResolvedValue([{ raw: 'hello', label: 'hello' }] as never);
const response = await api.get(`/api/prompts/${hash}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ data: [{ raw: 'hello', label: 'hello' }] });
expect(mockedGetPromptsForTestCasesHash).toHaveBeenCalledWith(hash);
});
it('rejects invalid prompt hash params', async () => {
const response = await api.get('/api/prompts/not-a-sha');
expect(response.status).toBe(400);
expect(response.body.error).toContain('sha256hash');
});
it('validates history query params and returns history DTOs', async () => {
mockedGetStandaloneEvals.mockResolvedValue([{ id: 'eval-1' }] as never);
const response = await api.get('/api/history?tagName=env&tagValue=prod');
expect(response.status).toBe(200);
expect(response.body).toEqual({ data: [{ id: 'eval-1' }] });
expect(mockedGetStandaloneEvals).toHaveBeenCalledWith({
tag: { key: 'env', value: 'prod' },
description: undefined,
});
});
it('parses prompt and dataset list response DTOs', async () => {
mockedGetPrompts.mockResolvedValue([{ raw: 'p', label: 'p' }] as never);
mockedGetTestCases.mockResolvedValue([{ vars: { q: 'hello' } }] as never);
const prompts = await api.get('/api/prompts');
const datasets = await api.get('/api/datasets');
expect(prompts.status).toBe(200);
expect(prompts.body).toEqual({ data: [{ raw: 'p', label: 'p' }] });
expect(datasets.status).toBe(200);
expect(datasets.body).toEqual({ data: [{ vars: { q: 'hello' } }] });
});
it('refreshes the prompt list route after invalidating the singleton cache', async () => {
mockedGetPrompts
.mockResolvedValueOnce([{ raw: 'first', label: 'first' }] as never)
.mockResolvedValueOnce([{ raw: 'second', label: 'second' }] as never);
const first = await api.get('/api/prompts');
promptCacheService.invalidate();
const second = await api.get('/api/prompts');
expect(first.status).toBe(200);
expect(first.body).toEqual({ data: [{ raw: 'first', label: 'first' }] });
expect(second.status).toBe(200);
expect(second.body).toEqual({ data: [{ raw: 'second', label: 'second' }] });
expect(mockedGetPrompts).toHaveBeenCalledTimes(2);
});
it('validates share-domain query params', async () => {
const response = await api.get('/api/results/share/check-domain');
expect(response.status).toBe(400);
expect(response.body.error).toContain('id');
expect(mockedEval.findById).not.toHaveBeenCalled();
});
it('returns share-domain DTOs for valid evals', async () => {
mockedEval.findById.mockResolvedValue({ id: 'eval-1' } as never);
mockedDetermineShareDomain.mockReturnValue({ domain: 'https://app.promptfoo.dev' } as never);
mockedCloudConfig.isEnabled.mockReturnValue(true);
const response = await api.get('/api/results/share/check-domain?id=eval-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({
domain: 'https://app.promptfoo.dev',
isCloudEnabled: true,
});
});
it('validates share request bodies and parses share responses', async () => {
mockedEval.findById.mockResolvedValue({ id: 'eval-1' } as never);
mockedCreateShareableUrl.mockResolvedValue('https://share.example/eval-1');
const invalid = await api.post('/api/results/share').send({});
const valid = await api.post('/api/results/share').send({ id: 'eval-1' });
expect(invalid.status).toBe(400);
expect(invalid.body.error).toContain('id');
expect(valid.status).toBe(200);
expect(valid.body).toEqual({ url: 'https://share.example/eval-1' });
// The share route intentionally avoids `readResult` because it converts
// load failures into `undefined`, which would collapse real DB errors into 404s.
expect(mockedReadResult).not.toHaveBeenCalled();
});
it('returns a 404 DTO when sharing a result whose eval row is missing', async () => {
mockedEval.findById.mockResolvedValue(null as never);
const response = await api.post('/api/results/share').send({ id: 'eval-1' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Eval not found' });
expect(mockedCreateShareableUrl).not.toHaveBeenCalled();
// Symmetric to the happy-path assertion above: the 404 branch must also
// never reach `readResult`. Otherwise a future refactor that reintroduces
// a `readResult` preflight only on the not-found path would silently
// resurrect the DB-error-as-404 regression.
expect(mockedReadResult).not.toHaveBeenCalled();
});
it('returns a 500 (not 404) when Eval.findById throws on a real DB error', async () => {
// This is the regression the PR fixes: pre-fix, `readResult` swallowed
// its own DB errors and returned undefined, which the route then
// classified as 404 "Eval not found". Routing the lookup through
// `Eval.findById` directly preserves the throw, and the route's
// try/catch funnels it through `sendError` for a structured 500.
mockedEval.findById.mockRejectedValueOnce(new Error('sqlite: database is locked'));
const response = await api.post('/api/results/share').send({ id: 'eval-1' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to load eval for share' });
expect(mockedCreateShareableUrl).not.toHaveBeenCalled();
});
it('does not rate-limit share URL creation attempts', async () => {
mockedEval.findById.mockResolvedValue(null as never);
const attempts = 35;
for (let i = 0; i < attempts; i++) {
const response = await api.post('/api/results/share').send({ id: `eval-${i}` });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Eval not found' });
}
expect(mockedEval.findById).toHaveBeenCalledTimes(attempts);
});
it('validates dataset generation request bodies', async () => {
mockedSynthesizeFromTestSuite.mockResolvedValue([{ vars: { q: 'generated' } }] as never);
const emptyPrompts = await api.post('/api/dataset/generate').send({ prompts: [], tests: [] });
const invalidPrompt = await api
.post('/api/dataset/generate')
.send({ prompts: [null], tests: [] });
const invalidTest = await api
.post('/api/dataset/generate')
.send({ prompts: ['Prompt'], tests: [null] });
const promptOnly = await api.post('/api/dataset/generate').send({ prompts: ['Prompt'] });
const valid = await api
.post('/api/dataset/generate')
.send({ prompts: ['Prompt'], tests: [{ vars: { q: 'seed' } }] });
expect(emptyPrompts.status).toBe(400);
expect(emptyPrompts.body.error).toContain('prompts');
expect(invalidPrompt.status).toBe(400);
expect(invalidPrompt.body.error).toContain('prompts');
expect(invalidTest.status).toBe(400);
expect(invalidTest.body.error).toContain('tests');
expect(promptOnly.status).toBe(200);
expect(promptOnly.body).toEqual({ results: [{ vars: { q: 'generated' } }] });
expect(valid.status).toBe(200);
expect(valid.body).toEqual({ results: [{ vars: { q: 'generated' } }] });
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
1,
{
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
tests: [],
providers: [],
},
{},
);
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
2,
{
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
tests: [{ vars: { q: 'seed' } }],
providers: [],
},
{},
);
});
it('validates telemetry request bodies and parses success DTOs', async () => {
mockedTelemetry.record.mockReturnValue(undefined);
const invalid = await api.post('/api/telemetry').send({ event: 'not-real' });
const valid = await api
.post('/api/telemetry')
.send({ event: 'webui_api', properties: { route: '/api/results' } });
expect(invalid.status).toBe(400);
expect(invalid.body.error).toContain('event');
expect(valid.status).toBe(200);
expect(valid.body).toEqual({ success: true });
expect(mockedTelemetry.record).toHaveBeenCalledWith('webui_api', { route: '/api/results' });
});
it('accepts the full telemetry property value matrix and rejects nested objects', async () => {
mockedTelemetry.record.mockReturnValue(undefined);
const matrix = await api.post('/api/telemetry').send({
event: 'webui_action',
properties: {
text: 'hi',
count: 3,
flag: true,
tags: ['a', 'b'],
},
});
const nested = await api
.post('/api/telemetry')
.send({ event: 'webui_action', properties: { nested: { k: 'v' } } });
expect(matrix.status).toBe(200);
expect(matrix.body).toEqual({ success: true });
expect(mockedTelemetry.record).toHaveBeenCalledWith('webui_action', {
text: 'hi',
count: 3,
flag: true,
tags: ['a', 'b'],
});
expect(nested.status).toBe(400);
expect(nested.body.error).toContain('properties');
});
it('returns a 500 DTO when telemetry recording throws', async () => {
mockedTelemetry.record.mockImplementation(() => {
throw new Error('posthog down');
});
const response = await api
.post('/api/telemetry')
.send({ event: 'webui_api', properties: { route: '/api/results' } });
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Failed to process telemetry request' });
});
it('returns the static health DTO without schema-parsing the response', async () => {
const response = await api.get('/health');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('status', 'OK');
expect(typeof response.body.version).toBe('string');
expect(response.body.version.length).toBeGreaterThan(0);
});
it('coerces includeProviders only for "true"/true; everything else resolves to false', async () => {
mockedGetEvalSummaries.mockResolvedValue([]);
const cases: { query: string; expected: boolean }[] = [
{ query: '?includeProviders=true', expected: true },
{ query: '?includeProviders=false', expected: false },
{ query: '?includeProviders=1', expected: false },
{ query: '?includeProviders=yes', expected: false },
{ query: '', expected: false },
];
for (const { query, expected } of cases) {
mockedGetEvalSummaries.mockClear();
const response = await api.get(`/api/results${query}`);
expect(response.status).toBe(200);
expect(mockedGetEvalSummaries).toHaveBeenCalledWith(undefined, undefined, expected);
}
});
it('rejects the literal string "undefined" on share check-domain', async () => {
const response = await api.get('/api/results/share/check-domain?id=undefined');
expect(response.status).toBe(400);
expect(response.body.error).toContain('id');
expect(mockedEval.findById).not.toHaveBeenCalled();
});
it('returns 404 from share check-domain when the eval is missing', async () => {
mockedEval.findById.mockResolvedValue(null as never);
const response = await api.get('/api/results/share/check-domain?id=eval-1');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Eval not found' });
expect(mockedDetermineShareDomain).not.toHaveBeenCalled();
});
it('normalizes object-form prompts in dataset generation', async () => {
mockedSynthesizeFromTestSuite.mockResolvedValue([{ vars: { q: 'generated' } }] as never);
const labelMissing = await api.post('/api/dataset/generate').send({
prompts: [{ raw: 'P1' }],
tests: [],
});
const labelPresent = await api.post('/api/dataset/generate').send({
prompts: [{ raw: 'P2', label: 'custom-label', extra: 'kept' }],
tests: [],
});
expect(labelMissing.status).toBe(200);
expect(labelPresent.status).toBe(200);
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
prompts: [{ raw: 'P1', label: 'P1' }],
}),
{},
);
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
prompts: [{ raw: 'P2', label: 'custom-label', extra: 'kept' }],
}),
{},
);
});
it('imports server DTO schemas without mutating the user config directory', () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-server-dto-import-'));
const importProbe = `
await import('./src/types/api/server.ts');
await import('./src/telemetryEvents.ts');
`;
try {
const result = spawnSync(process.execPath, ['--import', 'tsx', '-e', importProbe], {
cwd: path.resolve(__dirname, '../../..'),
encoding: 'utf8',
env: {
...process.env,
PROMPTFOO_CONFIG_DIR: configDir,
PROMPTFOO_DISABLE_TELEMETRY: 'true',
},
});
expect(result.status, result.stderr || result.stdout).toBe(0);
expect(fs.readdirSync(configDir)).toEqual([]);
} finally {
fs.rmSync(configDir, { recursive: true, force: true });
}
});
});
+878
View File
@@ -0,0 +1,878 @@
import { IncomingMessage, ServerResponse } from 'node:http';
import { Duplex } from 'node:stream';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createServerOpenApiDocument,
SERVER_OPENAPI_ROUTE_COUNT,
} from '../../../src/openapi/server';
import { createApp } from '../../../src/server/server';
import { promptCacheService } from '../../../src/server/services/promptCacheService';
import type { Express } from 'express';
const REQUEST_TIMEOUT_MS = 2000;
const mocks = vi.hoisted(() => ({
checkEmailStatus: vi.fn(),
checkModelAuditInstalled: vi.fn(),
checkRemoteHealth: vi.fn(),
clearUserEmail: vi.fn(),
cloudConfig: {
delete: vi.fn(),
getApiHost: vi.fn(),
getAppUrl: vi.fn(),
isEnabled: vi.fn(),
validateAndSetApiToken: vi.fn(),
},
createShareableUrl: vi.fn(),
deleteEval: vi.fn(),
deleteEvals: vi.fn(),
determineShareDomain: vi.fn(),
doRedteamRun: vi.fn(),
evalModel: {
create: vi.fn(),
findById: vi.fn(),
latest: vi.fn(),
},
evalQueries: {
getMetadataKeysFromEval: vi.fn(),
getMetadataValuesFromEval: vi.fn(),
},
evalResultModel: {
findById: vi.fn(),
},
fetchWithProxy: vi.fn(),
getAvailableProviders: vi.fn(),
getBlobByHash: vi.fn(),
getBlobUrl: vi.fn(),
getDb: vi.fn(),
getEnvBool: vi.fn(),
getEnvFloat: vi.fn(),
getEnvInt: vi.fn(),
getEnvString: vi.fn(),
getEvalSummaries: vi.fn(),
getLatestVersion: vi.fn(),
getMediaStats: vi.fn(),
getMediaStorage: vi.fn(),
getPrompts: vi.fn(),
getPromptsForTestCasesHash: vi.fn(),
getStandaloneEvals: vi.fn(),
getTestCases: vi.fn(),
getTraceStore: vi.fn(),
getUpdateCommands: vi.fn(),
getUserEmail: vi.fn(),
getUserId: vi.fn(),
isBlobStorageEnabled: vi.fn(),
isRunningUnderNpx: vi.fn(),
loadApiProvider: vi.fn(),
mediaExists: vi.fn(),
modelAudit: {
count: vi.fn(),
create: vi.fn(),
findById: vi.fn(),
getMany: vi.fn(),
},
neverGenerateRemote: vi.fn(),
promptfooEvaluate: vi.fn(),
readResult: vi.fn(),
retrieveMedia: vi.fn(),
setUserEmail: vi.fn(),
stripAuthFromUrl: vi.fn(),
synthesizeFromTestSuite: vi.fn(),
telemetry: {
record: vi.fn(),
saveConsent: vi.fn(),
},
testProviderConnectivity: vi.fn(),
testProviderSession: vi.fn(),
updateResult: vi.fn(),
writeResultsToDatabase: vi.fn(),
}));
vi.mock('../../../src/blobs', () => ({
getBlobByHash: mocks.getBlobByHash,
getBlobUrl: mocks.getBlobUrl,
}));
vi.mock('../../../src/blobs/extractor', () => ({
isBlobStorageEnabled: mocks.isBlobStorageEnabled,
}));
vi.mock('../../../src/util/modelAuditInstall', () => ({
checkModelAuditInstalled: mocks.checkModelAuditInstalled,
}));
vi.mock('../../../src/database', () => ({
getDb: mocks.getDb,
}));
vi.mock('../../../src/database/index', () => ({
getDb: mocks.getDb,
}));
vi.mock('../../../src/envars', () => ({
getEnvBool: mocks.getEnvBool,
getEnvFloat: mocks.getEnvFloat,
getEnvInt: mocks.getEnvInt,
getEvalTimeoutMs: vi.fn(() => 0),
getMaxEvalTimeMs: vi.fn(() => 0),
getEnvString: mocks.getEnvString,
isCI: vi.fn(() => false),
isNonInteractive: vi.fn(() => true),
}));
vi.mock('../../../src/globalConfig/accounts', () => ({
checkEmailStatus: mocks.checkEmailStatus,
clearUserEmail: mocks.clearUserEmail,
getUserEmail: mocks.getUserEmail,
getUserId: mocks.getUserId,
setUserEmail: mocks.setUserEmail,
}));
vi.mock('../../../src/globalConfig/cloud', () => ({
cloudConfig: mocks.cloudConfig,
}));
vi.mock('../../../src/node', () => ({
evaluate: mocks.promptfooEvaluate,
}));
vi.mock('../../../src/models/eval', () => ({
EvalQueries: mocks.evalQueries,
default: mocks.evalModel,
getEvalSummaries: mocks.getEvalSummaries,
}));
vi.mock('../../../src/models/evalResult', () => ({
default: mocks.evalResultModel,
}));
vi.mock('../../../src/models/modelAudit', () => ({
default: mocks.modelAudit,
}));
vi.mock('../../../src/providers/index', () => ({
loadApiProvider: mocks.loadApiProvider,
}));
vi.mock('../../../src/redteam/remoteGeneration', () => ({
getRemoteGenerationUrl: vi.fn(() => 'https://api.promptfoo.dev/task'),
getRemoteHealthUrl: vi.fn(() => null),
neverGenerateRemote: mocks.neverGenerateRemote,
}));
vi.mock('../../../src/redteam/shared', () => ({
doRedteamRun: mocks.doRedteamRun,
}));
vi.mock('../../../src/share', () => ({
createShareableUrl: mocks.createShareableUrl,
determineShareDomain: mocks.determineShareDomain,
stripAuthFromUrl: mocks.stripAuthFromUrl,
}));
vi.mock('../../../src/storage', () => ({
getMediaStorage: mocks.getMediaStorage,
mediaExists: mocks.mediaExists,
retrieveMedia: mocks.retrieveMedia,
}));
vi.mock('../../../src/telemetry', () => ({
default: mocks.telemetry,
}));
vi.mock('../../../src/testCase/synthesis', () => ({
synthesizeFromTestSuite: mocks.synthesizeFromTestSuite,
}));
vi.mock('../../../src/tracing/store', () => ({
getTraceStore: mocks.getTraceStore,
}));
vi.mock('../../../src/updates', () => ({
getLatestVersion: mocks.getLatestVersion,
}));
vi.mock('../../../src/updates/updateCommands', () => ({
getUpdateCommands: mocks.getUpdateCommands,
}));
vi.mock('../../../src/util/apiHealth', () => ({
checkRemoteHealth: mocks.checkRemoteHealth,
}));
vi.mock('../../../src/util/database', () => ({
deleteEval: mocks.deleteEval,
deleteEvals: mocks.deleteEvals,
getPrompts: mocks.getPrompts,
getPromptsForTestCasesHash: mocks.getPromptsForTestCasesHash,
getStandaloneEvals: mocks.getStandaloneEvals,
getTestCases: mocks.getTestCases,
readResult: mocks.readResult,
updateResult: mocks.updateResult,
writeResultsToDatabase: mocks.writeResultsToDatabase,
}));
vi.mock('../../../src/util/fetch/index', () => ({
fetchWithProxy: mocks.fetchWithProxy,
}));
vi.mock('../../../src/util/promptfooCommand', () => ({
isRunningUnderNpx: mocks.isRunningUnderNpx,
}));
vi.mock('../../../src/node/testProvider', () => ({
testProviderConnectivity: mocks.testProviderConnectivity,
testProviderSession: mocks.testProviderSession,
}));
vi.mock('../../../src/server/config/serverConfig', () => ({
getAvailableProviders: mocks.getAvailableProviders,
}));
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
type SmokeCase = {
method: HttpMethod;
openApiPath: string;
path: string;
expectedStatus: number;
body?: unknown;
rawJsonBody?: string;
setup?: () => void;
};
class MockSocket extends Duplex {
remoteAddress = '127.0.0.1';
_read() {
// This test double never emits readable data; IncomingMessage only needs the stream shape.
}
_write(_chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
// Accept and discard smoke-test transport writes.
callback();
}
}
function createThenableQuery(rows: unknown[] = []) {
const query = {
all: vi.fn(() => rows),
from: vi.fn(() => query),
get: vi.fn(() => undefined),
innerJoin: vi.fn(() => query),
leftJoin: vi.fn(() => query),
limit: vi.fn(() => query),
offset: vi.fn(() => query),
orderBy: vi.fn(() => query),
// biome-ignore lint/suspicious/noThenProperty: Drizzle select builders are thenables, and config routes await them directly.
then: (...args: Parameters<Promise<unknown[]>['then']>) => Promise.resolve(rows).then(...args),
where: vi.fn(() => query),
};
return query;
}
function createMockDb() {
return {
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([{ id: 'config-1', createdAt: 0 }])),
})),
})),
select: vi.fn(() => createThenableQuery([])),
selectDistinct: vi.fn(() => createThenableQuery([])),
};
}
function setupDefaultMocks() {
const mockDb = createMockDb();
mocks.getDb.mockReturnValue(mockDb);
mocks.checkEmailStatus.mockResolvedValue({ hasEmail: false, status: 'no_email' });
mocks.checkModelAuditInstalled.mockResolvedValue({ installed: false, version: null });
mocks.checkRemoteHealth.mockResolvedValue({ status: 'OK', message: 'healthy' });
mocks.cloudConfig.getApiHost.mockReturnValue('https://api.promptfoo.dev');
mocks.cloudConfig.getAppUrl.mockReturnValue('https://app.promptfoo.dev');
mocks.cloudConfig.isEnabled.mockReturnValue(false);
mocks.deleteEval.mockResolvedValue(undefined);
mocks.deleteEvals.mockReturnValue(undefined);
mocks.determineShareDomain.mockReturnValue({ domain: 'https://app.promptfoo.dev' });
mocks.evalModel.findById.mockResolvedValue(null);
mocks.getAvailableProviders.mockReturnValue([]);
mocks.getEnvBool.mockReturnValue(false);
mocks.getEnvFloat.mockReturnValue(undefined);
mocks.getEnvInt.mockReturnValue(undefined);
mocks.getEnvString.mockImplementation((_key, fallback) => fallback);
mocks.getEvalSummaries.mockResolvedValue([]);
mocks.getLatestVersion.mockResolvedValue('999.0.0');
mocks.getMediaStats.mockResolvedValue({ totalFiles: 0, totalSize: 0 });
mocks.getMediaStorage.mockReturnValue({
getStats: mocks.getMediaStats,
providerId: 'test-storage',
});
mocks.getPrompts.mockResolvedValue([]);
mocks.getPromptsForTestCasesHash.mockResolvedValue([]);
mocks.getStandaloneEvals.mockResolvedValue([]);
mocks.getTestCases.mockResolvedValue([]);
mocks.getTraceStore.mockReturnValue({
getTrace: vi.fn().mockResolvedValue(null),
getTracesByEvaluation: vi.fn().mockResolvedValue([]),
});
mocks.getUpdateCommands.mockReturnValue({
alternative: null,
commandType: 'npm',
primary: 'npm install -g promptfoo',
});
mocks.getUserEmail.mockReturnValue('');
mocks.getUserId.mockReturnValue('user-1');
mocks.isBlobStorageEnabled.mockReturnValue(false);
mocks.isRunningUnderNpx.mockReturnValue(false);
mocks.mediaExists.mockResolvedValue(false);
mocks.modelAudit.count.mockResolvedValue(0);
mocks.modelAudit.findById.mockResolvedValue(null);
mocks.modelAudit.getMany.mockResolvedValue([]);
mocks.neverGenerateRemote.mockReturnValue(false);
mocks.readResult.mockResolvedValue(undefined);
mocks.stripAuthFromUrl.mockImplementation((url) => url);
mocks.synthesizeFromTestSuite.mockResolvedValue([]);
mocks.telemetry.record.mockResolvedValue(undefined);
mocks.telemetry.saveConsent.mockResolvedValue(undefined);
mocks.updateResult.mockResolvedValue(undefined);
mocks.writeResultsToDatabase.mockResolvedValue('eval-1');
}
function documentedRouteKeys() {
const document = createServerOpenApiDocument();
return Object.entries(document.paths ?? {}).flatMap(([path, pathItem]) =>
HTTP_METHODS.flatMap((method) => {
const operation = (pathItem as Partial<Record<(typeof HTTP_METHODS)[number], unknown>>)?.[
method
];
return operation ? [`${method.toUpperCase()} ${path}`] : [];
}),
);
}
function routeKey(testCase: Pick<SmokeCase, 'method' | 'openApiPath'>) {
return `${testCase.method.toUpperCase()} ${testCase.openApiPath}`;
}
const validUuid = '00000000-0000-4000-8000-000000000000';
const validMediaFilename = 'abcdef123456.mp3';
const smokeCases: SmokeCase[] = [
{ method: 'get', openApiPath: '/health', path: '/health', expectedStatus: 200 },
{
method: 'get',
openApiPath: '/api/remote-health',
path: '/api/remote-health',
expectedStatus: 200,
},
{ method: 'get', openApiPath: '/api/results', path: '/api/results', expectedStatus: 200 },
{
method: 'get',
openApiPath: '/api/results/{id}',
path: '/api/results/missing-eval',
expectedStatus: 404,
},
{ method: 'get', openApiPath: '/api/prompts', path: '/api/prompts', expectedStatus: 200 },
{ method: 'get', openApiPath: '/api/history', path: '/api/history', expectedStatus: 200 },
{
method: 'get',
openApiPath: '/api/prompts/{sha256hash}',
path: '/api/prompts/not-a-sha',
expectedStatus: 400,
},
{ method: 'get', openApiPath: '/api/datasets', path: '/api/datasets', expectedStatus: 200 },
{
method: 'get',
openApiPath: '/api/results/share/check-domain',
path: '/api/results/share/check-domain',
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/results/share',
path: '/api/results/share',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/dataset/generate',
path: '/api/dataset/generate',
body: { prompts: [] },
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/telemetry',
path: '/api/telemetry',
body: {},
expectedStatus: 400,
},
{ method: 'get', openApiPath: '/api/configs', path: '/api/configs', expectedStatus: 200 },
{
method: 'post',
openApiPath: '/api/configs',
path: '/api/configs',
body: {},
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/configs/{type}',
path: '/api/configs/eval',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/configs/{type}/{id}',
path: '/api/configs/eval/missing-config',
expectedStatus: 404,
},
{
method: 'post',
openApiPath: '/api/eval/job',
path: '/api/eval/job',
body: { prompts: 'not-an-array' },
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/eval/job/{id}',
path: `/api/eval/job/${validUuid}`,
expectedStatus: 404,
},
{
method: 'patch',
openApiPath: '/api/eval/{id}',
path: '/api/eval/eval-1',
body: { table: 'not-a-table' },
expectedStatus: 400,
},
{
method: 'delete',
openApiPath: '/api/eval/{id}',
path: '/api/eval/eval-1',
expectedStatus: 200,
},
{
method: 'patch',
openApiPath: '/api/eval/{id}/author',
path: '/api/eval/eval-1/author',
body: { author: 'not-an-email' },
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/eval/{id}/table',
path: '/api/eval/eval-1/table?limit=abc',
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/eval/{id}/metadata-keys',
path: '/api/eval/ab/metadata-keys',
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/eval/{id}/metadata-values',
path: '/api/eval/eval-1/metadata-values',
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/eval/{id}/results',
path: '/api/eval/eval-1/results',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/eval/replay',
path: '/api/eval/replay',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/eval/{evalId}/results/{id}/rating',
path: '/api/eval/eval-1/results/result-1/rating',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/eval',
path: '/api/eval',
body: {},
expectedStatus: 400,
},
{
method: 'delete',
openApiPath: '/api/eval',
path: '/api/eval',
body: { ids: [] },
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/eval/{id}/copy',
path: '/api/eval/eval-1/copy',
body: { description: 123 },
expectedStatus: 400,
},
{ method: 'get', openApiPath: '/api/media/stats', path: '/api/media/stats', expectedStatus: 200 },
{
method: 'get',
openApiPath: '/api/media/info/{type}/{filename}',
path: `/api/media/info/audio/${validMediaFilename}`,
expectedStatus: 404,
},
{
method: 'get',
openApiPath: '/api/media/{type}/{filename}',
path: `/api/media/audio/${validMediaFilename}`,
expectedStatus: 404,
},
{
method: 'get',
openApiPath: '/api/model-audit/check-installed',
path: '/api/model-audit/check-installed',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/model-audit/scanners',
path: '/api/model-audit/scanners',
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/model-audit/check-path',
path: '/api/model-audit/check-path',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/model-audit/scan',
path: '/api/model-audit/scan',
body: {},
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/model-audit/scans',
path: '/api/model-audit/scans',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/model-audit/scans/latest',
path: '/api/model-audit/scans/latest',
expectedStatus: 404,
},
{
method: 'get',
openApiPath: '/api/model-audit/scans/{id}',
path: '/api/model-audit/scans/scan-1',
expectedStatus: 404,
},
{
method: 'delete',
openApiPath: '/api/model-audit/scans/{id}',
path: '/api/model-audit/scans/scan-1',
expectedStatus: 404,
},
{
method: 'get',
openApiPath: '/api/providers/config-status',
path: '/api/providers/config-status',
expectedStatus: 200,
},
{
method: 'post',
openApiPath: '/api/providers/test',
path: '/api/providers/test',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/providers/discover',
path: '/api/providers/discover',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/providers/http-generator',
path: '/api/providers/http-generator',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/providers/test-request-transform',
path: '/api/providers/test-request-transform',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/providers/test-response-transform',
path: '/api/providers/test-response-transform',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/providers/test-session',
path: '/api/providers/test-session',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/redteam/generate-test',
path: '/api/redteam/generate-test',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/redteam/run',
path: '/api/redteam/run',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/redteam/cancel',
path: '/api/redteam/cancel',
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/redteam/{taskId}',
path: '/api/redteam/task-1',
body: [],
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/redteam/status',
path: '/api/redteam/status',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/traces/evaluation/{evaluationId}',
path: '/api/traces/evaluation/eval-1',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/traces/{traceId}',
path: '/api/traces/trace-1',
expectedStatus: 404,
},
{ method: 'get', openApiPath: '/api/user/email', path: '/api/user/email', expectedStatus: 200 },
{
method: 'post',
openApiPath: '/api/user/email',
path: '/api/user/email',
body: {},
expectedStatus: 400,
},
{ method: 'get', openApiPath: '/api/user/id', path: '/api/user/id', expectedStatus: 200 },
{
method: 'put',
openApiPath: '/api/user/email/clear',
path: '/api/user/email/clear',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/user/email/status',
path: '/api/user/email/status',
expectedStatus: 200,
},
{
method: 'post',
openApiPath: '/api/user/login',
path: '/api/user/login',
body: {},
expectedStatus: 400,
},
{
method: 'post',
openApiPath: '/api/user/logout',
path: '/api/user/logout',
expectedStatus: 200,
},
{
method: 'get',
openApiPath: '/api/user/cloud-config',
path: '/api/user/cloud-config',
expectedStatus: 200,
},
{ method: 'get', openApiPath: '/api/version', path: '/api/version', expectedStatus: 200 },
{
method: 'get',
openApiPath: '/api/blobs/library',
path: '/api/blobs/library?limit=abc',
setup: () => mocks.isBlobStorageEnabled.mockReturnValue(true),
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/blobs/library/evals',
path: '/api/blobs/library/evals?limit=abc',
setup: () => mocks.isBlobStorageEnabled.mockReturnValue(true),
expectedStatus: 400,
},
{
method: 'get',
openApiPath: '/api/blobs/{hash}',
path: '/api/blobs/not-a-hash',
setup: () => mocks.isBlobStorageEnabled.mockReturnValue(true),
expectedStatus: 400,
},
];
async function sendRequest(app: Express, testCase: SmokeCase) {
const socket = new MockSocket();
const req = new IncomingMessage(socket as never);
req.method = testCase.method.toUpperCase();
req.url = testCase.path;
req.headers = {
accept: 'application/json',
host: '127.0.0.1',
};
let payload: Buffer | undefined;
if (testCase.rawJsonBody !== undefined) {
payload = Buffer.from(testCase.rawJsonBody);
} else if ('body' in testCase) {
payload = Buffer.from(JSON.stringify(testCase.body));
}
if (payload) {
req.headers['content-type'] = 'application/json';
req.headers['content-length'] = String(payload.length);
}
const res = new ServerResponse(req);
res.assignSocket(socket as never);
const chunks: Buffer[] = [];
const write = res.write.bind(res);
const end = res.end.bind(res);
res.write = ((chunk: unknown, encoding?: BufferEncoding | ((error?: Error) => void)) => {
if (chunk !== undefined) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return write(chunk as never, encoding as never);
}) as typeof res.write;
res.end = ((chunk?: unknown, encoding?: BufferEncoding | (() => void), callback?: () => void) => {
if (chunk !== undefined) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return end(chunk as never, encoding as never, callback);
}) as typeof res.end;
const response = new Promise<{
body: unknown;
headers: ReturnType<ServerResponse['getHeaders']>;
status: number;
text: string;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`${routeKey(testCase)} did not finish`));
}, REQUEST_TIMEOUT_MS);
res.once('finish', () => {
clearTimeout(timeout);
const text = Buffer.concat(chunks).toString('utf8');
let body: unknown = {};
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
resolve({
body,
headers: res.getHeaders(),
status: res.statusCode,
text,
});
});
res.once('error', reject);
socket.once('error', reject);
});
req.push(payload ?? null);
if (payload) {
req.push(null);
}
app(req, res);
return response;
}
describe('server route end-to-end smoke coverage', { concurrent: false }, () => {
let app: Express;
beforeAll(() => {
setupDefaultMocks();
app = createApp();
});
beforeEach(() => {
vi.resetAllMocks();
// promptCacheService is a module-level singleton; clear its cache so a prompt list cached
// by one test cannot leak into another.
promptCacheService.invalidate();
setupDefaultMocks();
});
afterEach(() => {
promptCacheService.invalidate();
vi.resetAllMocks();
});
it('has one smoke case for every generated OpenAPI route', () => {
const smokeKeys = smokeCases.map(routeKey).sort();
const documentedKeys = documentedRouteKeys().sort();
expect(smokeCases).toHaveLength(SERVER_OPENAPI_ROUTE_COUNT);
expect(new Set(smokeKeys).size).toBe(SERVER_OPENAPI_ROUTE_COUNT);
expect(smokeKeys).toEqual(documentedKeys);
});
it.each(smokeCases)('$method $openApiPath', async (testCase) => {
testCase.setup?.();
const response = await sendRequest(app, testCase);
expect(
response.status,
`${routeKey(testCase)} expected ${testCase.expectedStatus}, got ${response.status}: ${response.text}`,
).toBe(testCase.expectedStatus);
if (response.status !== 204) {
expect(response.headers['content-type']).toContain('application/json');
}
});
});
+162
View File
@@ -0,0 +1,162 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
// Mock dependencies
vi.mock('../../../src/tracing/store');
// Import after mocking
import { getTraceStore } from '../../../src/tracing/store';
const mockedGetTraceStore = vi.mocked(getTraceStore);
describe('Traces Routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
let mockGetTracesByEvaluation: ReturnType<typeof vi.fn>;
let mockGetTrace: ReturnType<typeof vi.fn>;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
vi.resetAllMocks();
// Setup mock trace store methods
mockGetTracesByEvaluation = vi.fn();
mockGetTrace = vi.fn();
mockedGetTraceStore.mockReturnValue({
getTracesByEvaluation: mockGetTracesByEvaluation,
getTrace: mockGetTrace,
} as any);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('GET /api/traces/evaluation/:evaluationId', () => {
it('should return traces array when traces exist', async () => {
const mockTraces = [
{
id: '1',
traceId: 'trace-1',
evaluationId: 'eval-123',
testCaseId: 'test-1',
spans: [],
},
{
id: '2',
traceId: 'trace-2',
evaluationId: 'eval-123',
testCaseId: 'test-2',
spans: [],
},
];
mockGetTracesByEvaluation.mockResolvedValue(mockTraces);
const response = await api.get('/api/traces/evaluation/eval-123');
expect(response.status).toBe(200);
expect(response.body).toEqual({
traces: mockTraces,
});
expect(mockGetTracesByEvaluation).toHaveBeenCalledWith('eval-123');
});
it('should return empty array when no traces found', async () => {
mockGetTracesByEvaluation.mockResolvedValue([]);
const response = await api.get('/api/traces/evaluation/eval-456');
expect(response.status).toBe(200);
expect(response.body).toEqual({
traces: [],
});
expect(mockGetTracesByEvaluation).toHaveBeenCalledWith('eval-456');
});
it('should return 500 on database error', async () => {
mockGetTracesByEvaluation.mockRejectedValue(new Error('Database connection failed'));
const response = await api.get('/api/traces/evaluation/eval-789');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to fetch traces',
});
});
});
describe('GET /api/traces/:traceId', () => {
it('should return trace when found', async () => {
const mockTrace = {
id: '1',
traceId: 'trace-abc',
evaluationId: 'eval-123',
testCaseId: 'test-1',
spans: [
{
id: 'span-1',
spanId: 'span-123',
name: 'api-call',
startTime: 1234567890,
endTime: 1234567900,
},
],
};
mockGetTrace.mockResolvedValue(mockTrace);
const response = await api.get('/api/traces/trace-abc');
expect(response.status).toBe(200);
expect(response.body).toEqual({
trace: mockTrace,
});
expect(mockGetTrace).toHaveBeenCalledWith('trace-abc');
});
it('should return 404 when trace not found', async () => {
mockGetTrace.mockResolvedValue(null);
const response = await api.get('/api/traces/trace-nonexistent');
expect(response.status).toBe(404);
expect(response.body).toEqual({
error: 'Trace not found',
});
expect(mockGetTrace).toHaveBeenCalledWith('trace-nonexistent');
});
it('should return 500 on database error', async () => {
mockGetTrace.mockRejectedValue(new Error('Database connection failed'));
const response = await api.get('/api/traces/trace-error');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to fetch trace',
});
});
});
});
+159
View File
@@ -0,0 +1,159 @@
import type { Server } from 'node:http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../../../src/server/server';
// Mock dependencies before imports
vi.mock('../../../src/globalConfig/accounts');
vi.mock('../../../src/globalConfig/cloud');
vi.mock('../../../src/telemetry');
// Import after mocking
import { checkEmailStatus, setUserEmail } from '../../../src/globalConfig/accounts';
import telemetry from '../../../src/telemetry';
const mockedSetUserEmail = vi.mocked(setUserEmail);
const mockedCheckEmailStatus = vi.mocked(checkEmailStatus);
const mockedTelemetry = vi.mocked(telemetry);
describe('User Routes', () => {
let api: ReturnType<typeof request.agent>;
let server: Server;
beforeAll(async () => {
await new Promise<void>((resolve, reject) => {
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
error ? reject(error) : resolve(),
);
});
api = request.agent(server);
});
afterAll(async () => {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
beforeEach(() => {
vi.resetAllMocks();
// Setup telemetry mock
mockedTelemetry.record = vi.fn().mockResolvedValue(undefined);
mockedTelemetry.saveConsent = vi.fn().mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('POST /api/user/email', () => {
it('should return 400 when body is empty', async () => {
const response = await api.post('/api/user/email').send({});
expect(response.status).toBe(400);
expect(response.body.error).toContain('email');
});
it('should return 400 when email is not a string', async () => {
const response = await api.post('/api/user/email').send({ email: 123 });
expect(response.status).toBe(400);
expect(response.body.error).toContain('email');
});
it('should return 200 when email is valid', async () => {
mockedSetUserEmail.mockImplementation(() => {});
const response = await api.post('/api/user/email').send({ email: 'test@example.com' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Email updated');
expect(mockedSetUserEmail).toHaveBeenCalledWith('test@example.com');
expect(mockedTelemetry.record).toHaveBeenCalled();
expect(mockedTelemetry.saveConsent).toHaveBeenCalled();
});
});
describe('GET /api/user/email/status', () => {
beforeEach(() => {
mockedCheckEmailStatus.mockResolvedValue({
hasEmail: true,
email: 'test@example.com',
status: 'ok',
});
});
it('should return 200 with default validate=false when no query param', async () => {
const response = await api.get('/api/user/email/status');
expect(response.status).toBe(200);
expect(response.body.hasEmail).toBe(true);
expect(response.body.email).toBe('test@example.com');
expect(response.body.status).toBe('ok');
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
});
it('should return 200 when validate=true', async () => {
const response = await api.get('/api/user/email/status?validate=true');
expect(response.status).toBe(200);
expect(response.body.hasEmail).toBe(true);
expect(response.body.email).toBe('test@example.com');
expect(response.body.status).toBe('ok');
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: true });
});
it('should return 200 when validate=yes (permissive - treated as false)', async () => {
const response = await api.get('/api/user/email/status?validate=yes');
expect(response.status).toBe(200);
expect(response.body.hasEmail).toBe(true);
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
});
it('should return 200 when validate=1 (permissive - treated as false)', async () => {
const response = await api.get('/api/user/email/status?validate=1');
expect(response.status).toBe(200);
expect(response.body.hasEmail).toBe(true);
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
});
it('should return 200 when validate is repeated (permissive - treated as false)', async () => {
const response = await api.get('/api/user/email/status?validate=true&validate=false');
expect(response.status).toBe(200);
expect(response.body.hasEmail).toBe(true);
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
});
});
describe('POST /api/user/login', () => {
it('should return 400 when body is empty', async () => {
const response = await api.post('/api/user/login').send({});
expect(response.status).toBe(400);
expect(response.body.error).toContain('apiKey');
});
it('should return 400 when apiKey is not a string', async () => {
const response = await api.post('/api/user/login').send({ apiKey: 123 });
expect(response.status).toBe(400);
expect(response.body.error).toContain('apiKey');
});
it('should return 400 when apiKey is empty string', async () => {
const response = await api.post('/api/user/login').send({ apiKey: '' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('API key is required');
});
});
});
+308
View File
@@ -0,0 +1,308 @@
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockProcessEnv } from '../../util/utils';
vi.mock('../../../src/updates', () => ({ getLatestVersion: vi.fn() }));
vi.mock('../../../src/updates/updateCommands', () => ({ getUpdateCommands: vi.fn() }));
vi.mock('../../../src/util/promptfooCommand', () => ({ isRunningUnderNpx: vi.fn() }));
import { createApp } from '../../../src/server/server';
import { getLatestVersion } from '../../../src/updates';
import { getUpdateCommands } from '../../../src/updates/updateCommands';
import { isRunningUnderNpx } from '../../../src/util/promptfooCommand';
const mockedGetLatestVersion = vi.mocked(getLatestVersion);
const mockedGetUpdateCommands = vi.mocked(getUpdateCommands);
const mockedIsRunningUnderNpx = vi.mocked(isRunningUnderNpx);
describe('Version Route', () => {
let app: ReturnType<typeof createApp>;
beforeEach(() => {
vi.resetAllMocks();
mockedIsRunningUnderNpx.mockReturnValue(false);
mockedGetUpdateCommands.mockReturnValue({
primary: 'npm install -g promptfoo@latest',
alternative: 'npx promptfoo@latest',
commandType: 'npm',
});
app = createApp();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should return 200 with valid response schema shape', async () => {
mockedGetLatestVersion.mockResolvedValue('99.0.0');
const response = await request(app).get('/api/version');
// If schema validation fails (e.g. wrong field names), Response.parse() throws → 500
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
selfHosted: false,
isNpx: false,
updateCommands: {
primary: 'npm install -g promptfoo@latest',
alternative: 'npx promptfoo@latest',
commandType: 'npm',
},
commandType: 'npm',
});
expect(typeof response.body.currentVersion).toBe('string');
expect(typeof response.body.latestVersion).toBe('string');
expect(typeof response.body.updateAvailable).toBe('boolean');
expect(typeof response.body.updateBlockedByRuntime).toBe('boolean');
if (process.versions.node.startsWith('20.')) {
expect(response.body.runtimeNotice).toMatchObject({ currentMajor: 20, runtime: 'node' });
expect(response.body.blockedUpdateNotice).toMatchObject({
currentMajor: 20,
runtime: 'node',
});
expect(response.body.runtimePolicy).toEqual({ supportEndDate: '2026-07-30' });
} else {
expect(response.body.runtimeNotice).toBeNull();
expect(response.body.blockedUpdateNotice).toBeNull();
expect(response.body.runtimePolicy).toBeNull();
}
});
it('should not return 500 when fetch fails (graceful fallback)', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2099-01-01T00:00:00.000Z'));
mockedGetLatestVersion.mockRejectedValue(new Error('Network error'));
const response = await request(app).get('/api/version');
// Should still return 200, not 500 — schema must match even on fallback path
expect(response.status).toBe(200);
expect(mockedGetLatestVersion).toHaveBeenCalledTimes(1);
expect(typeof response.body.currentVersion).toBe('string');
expect(typeof response.body.latestVersion).toBe('string');
expect(typeof response.body.updateAvailable).toBe('boolean');
expect(typeof response.body.updateBlockedByRuntime).toBe('boolean');
});
it('should skip upstream update checks when they are disabled', async () => {
const restoreEnv = mockProcessEnv({ PROMPTFOO_DISABLE_UPDATE: 'true' });
try {
const response = await request(app).get('/api/version');
expect(response.status).toBe(200);
expect(mockedGetLatestVersion).not.toHaveBeenCalled();
expect(response.body.latestVersion).toBe(response.body.currentVersion);
expect(response.body.updateAvailable).toBe(false);
if (process.versions.node.startsWith('20.')) {
expect(response.body.runtimePolicy).toEqual({ supportEndDate: '2026-07-30' });
}
} finally {
restoreEnv();
}
});
it('should preserve blocked update guidance when runtime reminders are disabled', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-07-31T00:00:00.000Z'));
vi.spyOn(process, 'version', 'get').mockReturnValue('v20.20.0');
const restoreEnv = mockProcessEnv({ PROMPTFOO_DISABLE_RUNTIME_WARNINGS: 'true' });
mockedGetLatestVersion.mockResolvedValue('99.0.0');
try {
const response = await request(app).get('/api/version');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
latestVersion: '99.0.0',
updateAvailable: false,
updateBlockedByRuntime: true,
runtimeNotice: null,
blockedUpdateNotice: {
currentVersion: 'v20.20.0',
currentMajor: 20,
removalDate: '2026-07-30',
minimumVersion: '22.22.0',
recommendedVersion: '24 LTS',
},
});
} finally {
restoreEnv();
}
});
it('should include all required fields matching UpdateCommandResult shape', async () => {
mockedGetLatestVersion.mockResolvedValue('99.0.0');
mockedGetUpdateCommands.mockReturnValue({
primary: 'docker pull ghcr.io/promptfoo/promptfoo:latest',
alternative: null,
commandType: 'docker',
});
const response = await request(app).get('/api/version');
expect(response.status).toBe(200);
// Validates that updateCommands has primary/alternative (not global/npx)
expect(response.body.updateCommands).toHaveProperty('primary');
expect(response.body.updateCommands).toHaveProperty('alternative');
expect(response.body.updateCommands).toHaveProperty('commandType');
expect(response.body.updateCommands).not.toHaveProperty('global');
expect(response.body.updateCommands).not.toHaveProperty('npx');
expect(['docker', 'npx', 'npm']).toContain(response.body.commandType);
});
it('should not classify generic self-hosted mode as Docker', async () => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
PROMPTFOO_SELF_HOSTED: 'true',
});
mockedGetLatestVersion.mockResolvedValue('99.0.0');
try {
const response = await request(app).get('/api/version');
expect(response.status).toBe(200);
expect(response.body.selfHosted).toBe(true);
expect(mockedGetUpdateCommands).toHaveBeenCalledWith({
isContainer: false,
isOfficialDockerImage: false,
isNpx: false,
});
} finally {
restoreEnv();
}
});
it('should use Docker guidance only when the official-image marker is set', async () => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: 'true',
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
PROMPTFOO_SELF_HOSTED: 'true',
});
mockedGetLatestVersion.mockResolvedValue('99.0.0');
try {
const response = await request(app).get('/api/version');
expect(response.status).toBe(200);
expect(response.body.selfHosted).toBe(true);
expect(mockedGetUpdateCommands).toHaveBeenCalledWith({
isContainer: true,
isOfficialDockerImage: true,
isNpx: false,
});
} finally {
restoreEnv();
}
});
it('should distinguish custom containers from official images', async () => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
PROMPTFOO_SELF_HOSTED: 'true',
});
mockedGetLatestVersion.mockResolvedValue('99.0.0');
mockedGetUpdateCommands.mockReturnValue({
primary: '',
alternative: null,
commandType: 'npm',
isCustomContainer: true,
});
try {
const response = await request(app).get('/api/version');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
commandType: 'npm',
updateCommands: {
primary: '',
alternative: null,
commandType: 'npm',
isCustomContainer: true,
},
});
expect(mockedGetUpdateCommands).toHaveBeenCalledWith({
isContainer: true,
isOfficialDockerImage: false,
isNpx: false,
});
} finally {
restoreEnv();
}
});
it('should block package updates but preserve Docker updates at the cutoff', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2099-01-01T00:00:00.000Z'));
mockedGetLatestVersion.mockRejectedValueOnce(new Error('Seed future retry state'));
const futureResponse = await request(app).get('/api/version');
expect(futureResponse.status).toBe(200);
mockedGetLatestVersion.mockReset();
mockedGetLatestVersion.mockResolvedValue('99.0.0');
vi.setSystemTime(new Date('2026-07-30T00:00:00.000Z'));
const packageResponse = await request(app).get('/api/version');
expect(packageResponse.status).toBe(200);
expect(mockedGetLatestVersion).toHaveBeenCalledTimes(1);
expect(packageResponse.body.updateBlockedByRuntime).toBe(
process.versions.node.startsWith('20.'),
);
expect(packageResponse.body.updateAvailable).toBe(!process.versions.node.startsWith('20.'));
expect(packageResponse.body.blockedUpdateNotice).toEqual(
process.versions.node.startsWith('20.')
? expect.objectContaining({ currentMajor: 20 })
: null,
);
mockedGetUpdateCommands.mockReturnValue({
primary: '',
alternative: null,
commandType: 'npm',
isCustomContainer: true,
});
const customContainerResponse = await request(app).get('/api/version');
expect(customContainerResponse.status).toBe(200);
expect(customContainerResponse.body.updateBlockedByRuntime).toBe(
process.versions.node.startsWith('20.'),
);
expect(customContainerResponse.body.updateAvailable).toBe(
!process.versions.node.startsWith('20.'),
);
expect(customContainerResponse.body.blockedUpdateNotice).toEqual(
process.versions.node.startsWith('20.')
? expect.objectContaining({ currentMajor: 20 })
: null,
);
mockedGetUpdateCommands.mockReturnValue({
primary: 'docker pull ghcr.io/promptfoo/promptfoo:latest',
alternative: null,
commandType: 'docker',
});
const dockerResponse = await request(app).get('/api/version');
expect(dockerResponse.status).toBe(200);
expect(dockerResponse.body.updateBlockedByRuntime).toBe(false);
expect(dockerResponse.body.updateAvailable).toBe(true);
expect(dockerResponse.body.blockedUpdateNotice).toBeNull();
});
it('should return 500 with fallback response when schema parse fails', async () => {
mockedGetLatestVersion.mockResolvedValue('99.0.0');
// Return an invalid shape that will cause VersionSchemas.Response.parse() to throw
mockedGetUpdateCommands.mockReturnValue({
primary: 'npm install -g promptfoo@latest',
alternative: 'npx promptfoo@latest',
commandType: 'invalid-type' as any,
});
const response = await request(app).get('/api/version');
expect(response.status).toBe(500);
expect(response.body).toHaveProperty('error', 'Failed to check version');
expect(response.body).toHaveProperty('currentVersion');
expect(response.body).toHaveProperty('updateCommands');
});
});
+162
View File
@@ -0,0 +1,162 @@
import semverGt from 'semver/functions/gt.js';
import semverValid from 'semver/functions/valid.js';
import { describe, expect, it } from 'vitest';
import {
getRuntimeNoticeForVersionResponse,
getRuntimePolicyForVersionResponse,
isUpdateAvailableForRuntime,
} from '../../../src/server/routes/versionUtils';
/**
* These tests verify the logic used in src/server/routes/version.ts
* for determining when to show the update banner.
*
* The version route has three key behaviors:
* 1. Semantic version comparison (not string comparison)
* 2. Development builds never show update banner
* 3. Failed fetches are rate-limited to prevent API hammering
*/
// Replicated from version.ts for testing
function isDevVersion(version: string): boolean {
return version.includes('development') || version === '0.0.0';
}
function isUpdateAvailable(latestVersion: string | null, currentVersion: string): boolean {
if (!latestVersion) {
return false;
}
if (isDevVersion(currentVersion)) {
return false;
}
if (semverValid(latestVersion) && semverValid(currentVersion)) {
return semverGt(latestVersion, currentVersion);
}
return latestVersion !== currentVersion;
}
describe('isDevVersion', () => {
it('should return true for development version strings', () => {
expect(isDevVersion('0.0.0-development')).toBe(true);
expect(isDevVersion('1.0.0-development')).toBe(true);
expect(isDevVersion('development')).toBe(true);
});
it('should return true for 0.0.0', () => {
expect(isDevVersion('0.0.0')).toBe(true);
});
it('should return false for normal version strings', () => {
expect(isDevVersion('1.0.0')).toBe(false);
expect(isDevVersion('0.120.9')).toBe(false);
expect(isDevVersion('1.2.3-beta.1')).toBe(false);
});
});
describe('isUpdateAvailable', () => {
describe('basic version comparison', () => {
it('should return true when latest version is greater', () => {
expect(isUpdateAvailable('1.1.0', '1.0.0')).toBe(true);
expect(isUpdateAvailable('2.0.0', '1.9.9')).toBe(true);
expect(isUpdateAvailable('1.0.1', '1.0.0')).toBe(true);
});
it('should return false when versions are equal', () => {
expect(isUpdateAvailable('1.0.0', '1.0.0')).toBe(false);
expect(isUpdateAvailable('0.120.9', '0.120.9')).toBe(false);
});
it('should return false when current version is greater (user on pre-release)', () => {
// BUG FIX: User on beta/pre-release should NOT see "downgrade" prompt
expect(isUpdateAvailable('1.0.0', '1.1.0-beta.1')).toBe(false);
expect(isUpdateAvailable('1.0.0', '2.0.0-alpha')).toBe(false);
});
});
describe('development builds', () => {
it('should return false for development versions regardless of latest', () => {
// BUG FIX: Development builds should never show update banner
expect(isUpdateAvailable('1.0.0', '0.0.0-development')).toBe(false);
expect(isUpdateAvailable('99.99.99', '0.0.0-development')).toBe(false);
});
it('should return false for 0.0.0 versions', () => {
expect(isUpdateAvailable('1.0.0', '0.0.0')).toBe(false);
});
});
describe('null/missing latest version', () => {
it('should return false when latestVersion is null', () => {
expect(isUpdateAvailable(null, '1.0.0')).toBe(false);
});
});
describe('pre-release versions', () => {
it('should correctly compare pre-release versions', () => {
// Pre-release is less than release
expect(isUpdateAvailable('1.0.0', '1.0.0-beta.1')).toBe(true);
expect(isUpdateAvailable('1.0.0', '1.0.0-alpha')).toBe(true);
// Later pre-release
expect(isUpdateAvailable('1.0.0-beta.2', '1.0.0-beta.1')).toBe(true);
});
it('should return false when user is on newer pre-release', () => {
// User on RC should not be prompted to "update" to stable if RC is newer
expect(isUpdateAvailable('1.0.0', '1.0.1-rc.1')).toBe(false);
});
});
describe('edge cases', () => {
it('should fall back to string comparison for invalid semver (different part counts)', () => {
// '1.0' is not valid semver (requires 3 parts), so falls back to string comparison
// Since '1.0.0' !== '1.0', it returns true (strings are different)
expect(isUpdateAvailable('1.0.0', '1.0')).toBe(true);
});
it('should fall back to string comparison for non-semver versions', () => {
// If somehow we get non-semver strings, use string inequality
expect(isUpdateAvailable('abc', 'def')).toBe(true); // Different strings
expect(isUpdateAvailable('abc', 'abc')).toBe(false); // Same strings
});
});
});
describe('getRuntimeNoticeForVersionResponse', () => {
it('should omit runtime notices when runtime warnings are disabled', () => {
expect(getRuntimeNoticeForVersionResponse('v20.20.2', true)).toBeNull();
});
it('should return applicable runtime notices when warnings are enabled', () => {
expect(getRuntimeNoticeForVersionResponse('v20.20.2', false)).toMatchObject({
currentMajor: 20,
id: 'node20-removal-2026-07-30',
});
});
});
describe('getRuntimePolicyForVersionResponse', () => {
it('should retain cutoff metadata when runtime warnings are disabled', () => {
expect(getRuntimePolicyForVersionResponse('v20.20.2')).toEqual({
supportEndDate: '2026-07-30',
});
});
it('should omit cutoff metadata for unaffected runtimes', () => {
expect(getRuntimePolicyForVersionResponse('v22.22.0')).toBeNull();
});
});
describe('isUpdateAvailableForRuntime', () => {
it('should hide runtime-blocked updates from legacy clients', () => {
expect(isUpdateAvailableForRuntime(true, true)).toBe(false);
});
it('should preserve compatible updates such as Docker image pulls', () => {
expect(isUpdateAvailableForRuntime(true, false)).toBe(true);
expect(isUpdateAvailableForRuntime(false, false)).toBe(false);
});
});
+741
View File
@@ -0,0 +1,741 @@
import { EventEmitter } from 'node:events';
import http from 'node:http';
import { Server as SocketIOServer } from 'socket.io';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies before importing the module
vi.mock('../../src/migrate', () => ({
runDbMigrations: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/database/signal', async () => {
const actual = await vi.importActual('../../src/database/signal');
return {
// Keep the real (pure) signal classifiers so the watcher's emit decisions are exercised.
...actual,
readSignalFile: vi.fn().mockReturnValue({ type: 'update' }),
setupSignalWatcher: vi.fn().mockReturnValue({
close: vi.fn(),
on: vi.fn(),
}),
};
});
vi.mock('../../src/models/evalMutation', () => ({
invalidateEvaluationCache: vi.fn(),
invalidateEvaluationCaches: vi.fn(),
}));
vi.mock('../../src/util/server', () => ({
BrowserBehavior: { OPEN: 0, SKIP: 1, ASK: 2 },
BrowserBehaviorNames: { 0: 'OPEN', 1: 'SKIP', 2: 'ASK' },
openBrowser: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/models/eval', () => ({
default: {
findById: vi.fn().mockResolvedValue(null),
latest: vi.fn().mockResolvedValue(null),
},
getEvalSummaries: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/server/services/promptCacheService', () => ({
promptCacheService: {
getAll: vi.fn().mockResolvedValue([]),
invalidate: vi.fn(),
},
}));
import { readSignalFile, setupSignalWatcher } from '../../src/database/signal';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import {
invalidateEvaluationCache,
invalidateEvaluationCaches,
} from '../../src/models/evalMutation';
// Import after mocks are set up
import { ServerError } from '../../src/server/errors';
import { handleServerError, startServer } from '../../src/server/server';
import { promptCacheService } from '../../src/server/services/promptCacheService';
describe('server', () => {
describe('handleServerError', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should log specific message for EADDRINUSE error', () => {
const error = new Error('Address in use') as NodeJS.ErrnoException;
error.code = 'EADDRINUSE';
const startupError = handleServerError(error, 3000);
expect(logger.error).toHaveBeenCalledWith(
'Port 3000 is already in use. Do you have another Promptfoo instance running?',
);
expect(startupError).toBeInstanceOf(ServerError);
expect(startupError).toMatchObject({
code: 'EADDRINUSE',
phase: 'startup',
message: 'Port 3000 is already in use. Do you have another Promptfoo instance running?',
port: 3000,
});
});
it('should log generic message for other errors', () => {
const error = new Error('Some other error') as NodeJS.ErrnoException;
error.code = 'ENOENT';
const startupError = handleServerError(error, 3000);
expect(logger.error).toHaveBeenCalledWith('Failed to start server: Some other error');
expect(startupError).toBeInstanceOf(ServerError);
expect(startupError).toMatchObject({
code: 'ENOENT',
phase: 'startup',
message: 'Failed to start server: Some other error',
port: 3000,
});
});
});
describe('startServer shutdown behavior', () => {
type MockHttpServer = EventEmitter & {
listen: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
listening: boolean;
};
let mockHttpServer: MockHttpServer;
let originalCreateServer: typeof http.createServer;
let signalHandlers: Map<string | symbol, ((...args: unknown[]) => void)[]>;
beforeEach(() => {
vi.clearAllMocks();
// Track signal handlers using a Map to support multiple handlers per event
signalHandlers = new Map();
vi.spyOn(process, 'once').mockImplementation(
(event: string | symbol, handler: (...args: unknown[]) => void) => {
if (event === 'SIGINT' || event === 'SIGTERM') {
if (!signalHandlers.has(event)) {
signalHandlers.set(event, []);
}
signalHandlers.get(event)!.push(handler);
}
return process;
},
);
// Create mock HTTP server
mockHttpServer = Object.assign(new EventEmitter(), {
listening: false,
listen: vi.fn().mockImplementation(function (
this: MockHttpServer,
_port: number,
callback: () => void,
) {
// Simulate async listen - server is now listening
this.listening = true;
setImmediate(() => callback());
return this;
}),
close: vi.fn().mockImplementation(function (
this: MockHttpServer,
callback?: (err?: Error) => void,
) {
this.listening = false;
setImmediate(() => callback?.());
}),
});
// Mock http.createServer
originalCreateServer = http.createServer;
(http.createServer as unknown) = vi.fn().mockReturnValue(mockHttpServer);
});
afterEach(() => {
http.createServer = originalCreateServer;
vi.restoreAllMocks();
});
const triggerSignal = (signal: 'SIGINT' | 'SIGTERM') => {
const handlers = signalHandlers.get(signal);
if (handlers) {
for (const handler of handlers) {
handler();
}
}
};
it('should register SIGINT and SIGTERM handlers', async () => {
// Start server and immediately trigger shutdown
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
expect(signalHandlers.has('SIGINT')).toBe(true);
expect(signalHandlers.has('SIGTERM')).toBe(true);
// Trigger shutdown to complete the promise
triggerSignal('SIGINT');
await serverPromise;
});
it('should reject startup errors without exiting the process', async () => {
const startupError = new Error('Address in use') as NodeJS.ErrnoException;
startupError.code = 'EADDRINUSE';
const mockWatcher = { close: vi.fn(), on: vi.fn() };
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
mockHttpServer.listen = vi.fn().mockImplementation(function (this: MockHttpServer) {
setImmediate(() => this.emit('error', startupError));
return this;
});
await expect(startServer(3000)).rejects.toMatchObject({
name: 'ServerError',
code: 'EADDRINUSE',
phase: 'startup',
message: 'Port 3000 is already in use. Do you have another Promptfoo instance running?',
port: 3000,
});
expect(mockWatcher.close).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
'Port 3000 is already in use. Do you have another Promptfoo instance running?',
);
});
it('should close the live server before rejecting runtime server errors', async () => {
const runtimeError = new Error('Unexpected runtime failure') as NodeJS.ErrnoException;
const mockWatcher = { close: vi.fn(), on: vi.fn() };
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
const serverPromise = startServer(0);
await vi.waitFor(() =>
expect(logger.info).toHaveBeenCalledWith(
'Server running at http://localhost:0 and monitoring for new evals.',
),
);
mockHttpServer.emit('error', runtimeError);
await expect(serverPromise).rejects.toMatchObject({
name: 'ServerError',
phase: 'runtime',
message: 'Server error: Unexpected runtime failure',
port: 0,
});
expect(mockWatcher.close).toHaveBeenCalled();
expect(mockHttpServer.close).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith('Server error: Unexpected runtime failure');
});
it('should close file watcher on shutdown', async () => {
const mockWatcher = { close: vi.fn(), on: vi.fn() };
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGINT
triggerSignal('SIGINT');
await serverPromise;
expect(mockWatcher.close).toHaveBeenCalled();
});
it('should invalidate process-local evaluation caches when the signal file changes', async () => {
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
// The default unscoped update can touch any eval's cached count, so it clears all of them
// (no-arg call) rather than scoping the clear to a single id.
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
expect(invalidateEvaluationCaches).not.toHaveBeenCalled();
await new Promise((resolve) => setImmediate(resolve));
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache on every signal', async () => {
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache even when the signal emits no eval update', async () => {
// Regression: a signal whose eval has no surviving record (here a scoped update for a
// missing eval) emits nothing, but cached `/api/prompts` output must still be dropped so a
// later request re-reads the DB. Invalidation must not be gated on a non-empty result/emit.
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'missing-eval' });
vi.mocked(Eval.findById).mockResolvedValueOnce(undefined as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
await new Promise((resolve) => setImmediate(resolve));
expect(emitSpy).not.toHaveBeenCalledWith('update', expect.anything());
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache for a delete signal', async () => {
// A delete takes a different branch than an update; the prompt cache must still be dropped.
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete', deletedEvalIds: ['gone'] });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache for a delete-all signal', async () => {
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete' });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
triggerSignal('SIGINT');
await serverPromise;
});
it('should scope process-local evaluation cache invalidation to the signaled eval', async () => {
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'update',
evalId: 'eval-12345-abcdef',
});
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['eval-12345-abcdef']);
await new Promise((resolve) => setImmediate(resolve));
triggerSignal('SIGINT');
await serverPromise;
});
it('should emit scoped updates for legacy evals without normalized result rows', async () => {
const getResultsCount = vi.fn().mockResolvedValue(0);
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'legacy-eval' });
vi.mocked(Eval.findById).mockResolvedValueOnce({
id: 'legacy-eval',
config: {},
getResultsCount,
} as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(logger.debug).toHaveBeenCalledWith('Emitting update for eval: legacy-eval'),
);
expect(getResultsCount).not.toHaveBeenCalled();
triggerSignal('SIGINT');
await serverPromise;
});
it('should emit an empty update when no evals remain after a mutation', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', null));
triggerSignal('SIGINT');
await serverPromise;
});
it('should ignore scoped updates for evals that were not persisted', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'missing-eval' });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await new Promise((resolve) => setImmediate(resolve));
expect(emitSpy).not.toHaveBeenCalledWith('update', null);
expect(emitSpy).not.toHaveBeenCalledWith('update', { evalId: 'missing-eval' });
triggerSignal('SIGINT');
await serverPromise;
});
it('should broadcast the latest eval for an unscoped mutation', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(Eval.latest).mockResolvedValueOnce({ id: 'surviving-eval' } as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'surviving-eval' }),
);
triggerSignal('SIGINT');
await serverPromise;
});
it('emits the latest eval for an unscoped refresh coalesced with a scoped update', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// A bare unscoped update coalesced with a scoped one (refreshLatest marker): the pinned eval
// refreshes AND the root view still follows the latest eval.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'update',
evalId: 'eval-scoped',
updatedEvalIds: ['eval-scoped'],
refreshLatest: true,
});
vi.mocked(Eval.findById).mockResolvedValueOnce({ id: 'eval-scoped', config: {} } as never);
vi.mocked(Eval.latest).mockResolvedValueOnce({ id: 'eval-latest', config: {} } as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-scoped' }),
);
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-latest' }),
);
// An unscoped refresh clears all count caches (the latest eval id is not known at signal time).
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
triggerSignal('SIGINT');
await serverPromise;
});
it('should broadcast deleted eval IDs without selecting a surviving eval', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'delete',
deletedEvalIds: ['deleted-eval'],
});
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['deleted-eval'] }),
);
// The deleted eval's count cache must be cleared so a re-import can't reuse a stale count.
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['deleted-eval']);
expect(invalidateEvaluationCache).not.toHaveBeenCalled();
triggerSignal('SIGINT');
await serverPromise;
});
it('should broadcast an empty id list when a delete signal omits ids (delete-all)', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// delete-all writes `{type:'delete'}` with no deletedEvalIds; the watcher should still
// broadcast a delete with an empty list so clients clear/reload.
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete' });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: [] }),
);
// A delete-all can invalidate any eval's count, so it clears every cache (no-arg call).
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
expect(invalidateEvaluationCaches).not.toHaveBeenCalled();
triggerSignal('SIGINT');
await serverPromise;
});
it('should emit both a delete and an update for a coalesced delete+update signal', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// A delete that folded in a pending scoped update (two mutations coalesced in the window)
// must broadcast the removed eval AND refresh clients to the surviving update.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'delete',
deletedEvalIds: ['deleted-eval'],
evalId: 'surviving-eval',
});
vi.mocked(Eval.findById).mockResolvedValueOnce({
id: 'surviving-eval',
config: {},
} as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['deleted-eval'] }),
);
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'surviving-eval' }),
);
// Both the surviving update AND the deleted eval are invalidated — the delete component of a
// coalesced signal must not leave the removed eval's count cache stale.
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['surviving-eval', 'deleted-eval']);
triggerSignal('SIGINT');
await serverPromise;
});
it('emits an update per eval for a coalesced multi-update signal', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// Two back-to-back scoped updates coalesced in the window must each refresh their clients.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'update',
evalId: 'eval-Z',
updatedEvalIds: ['eval-Y', 'eval-Z'],
});
// The watcher resolves the ids in order; mockResolvedValueOnce avoids leaking a persistent
// implementation into other (randomly-ordered) tests.
vi.mocked(Eval.findById)
.mockResolvedValueOnce({ id: 'eval-Y', config: {} } as never)
.mockResolvedValueOnce({ id: 'eval-Z', config: {} } as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-Y' }));
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-Z' }));
// Every coalesced eval's process-local caches are invalidated, not just the latest.
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['eval-Y', 'eval-Z']);
triggerSignal('SIGINT');
await serverPromise;
});
it('does not resurrect a deleted eval when a coalesced delete carries its own id', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// Pathological coalescing: the carried update id is also in deletedEvalIds. The delete
// must broadcast, but the update component must NOT emit (findById -> null) — and it must
// not fall back to broadcasting the latest eval, since the signal was scoped.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'delete',
deletedEvalIds: ['doomed-eval'],
evalId: 'doomed-eval',
});
vi.mocked(Eval.findById).mockResolvedValueOnce(undefined as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['doomed-eval'] }),
);
// Give the async update branch a chance to (not) emit.
await new Promise((resolve) => setImmediate(resolve));
expect(emitSpy).not.toHaveBeenCalledWith('update', { evalId: 'doomed-eval' });
expect(emitSpy).not.toHaveBeenCalledWith('update', null);
triggerSignal('SIGINT');
await serverPromise;
});
it('keeps the server alive when a signal-triggered eval lookup rejects', async () => {
// The watcher callback is fire-and-forget; a rejected DB lookup must be caught and logged
// rather than becoming an unhandledRejection that tears down the long-running view server.
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'boom-eval' });
vi.mocked(Eval.findById).mockRejectedValueOnce(new Error('libsql exploded'));
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to handle eval signal update'),
),
);
// The server is still running and shuts down cleanly.
triggerSignal('SIGINT');
await expect(serverPromise).resolves.toBeUndefined();
});
it('should not deadlock the shutdown promise if watcher.close throws', async () => {
const mockWatcher = {
close: vi.fn().mockImplementation(() => {
throw new Error('watcher already closed');
}),
on: vi.fn(),
};
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
triggerSignal('SIGINT');
await expect(serverPromise).resolves.toBeUndefined();
expect(mockWatcher.close).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Error closing file watcher'),
);
});
it('should not deadlock startup-error rejection if watcher.close throws', async () => {
const startupError = new Error('Address in use') as NodeJS.ErrnoException;
startupError.code = 'EADDRINUSE';
const mockWatcher = {
close: vi.fn().mockImplementation(() => {
throw new Error('watcher unhappy');
}),
on: vi.fn(),
};
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
mockHttpServer.listen = vi.fn().mockImplementation(function (this: MockHttpServer) {
setImmediate(() => this.emit('error', startupError));
return this;
});
await expect(startServer(3000)).rejects.toMatchObject({
name: 'ServerError',
phase: 'startup',
});
expect(mockWatcher.close).toHaveBeenCalled();
});
it('should log server closure', async () => {
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGINT
triggerSignal('SIGINT');
await serverPromise;
expect(logger.info).toHaveBeenCalledWith('Shutting down server...');
expect(logger.info).toHaveBeenCalledWith('Server closed');
});
it('should handle httpServer.close error gracefully', async () => {
// Mock close to call callback with error
mockHttpServer.close = vi.fn().mockImplementation(function (
callback?: (err?: Error) => void,
) {
setImmediate(() => callback?.(new Error('Close failed')));
});
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGINT
triggerSignal('SIGINT');
await serverPromise;
expect(logger.warn).toHaveBeenCalledWith('Error closing server: Close failed');
expect(logger.info).toHaveBeenCalledWith('Server closed');
});
// Note: Testing the case where socket.io already closed the HTTP server would require
// mocking socket.io at the module level. The fix (checking httpServer.listening) handles
// this case in production where io.close() closes the underlying server.
//
// Note: Testing the 5-second force shutdown timeout requires mocking socket.io
// at the module level, which is complex. The timeout exists as a safety measure
// and the core shutdown logic is tested by the other tests.
it('should handle SIGTERM the same as SIGINT', async () => {
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGTERM instead of SIGINT
triggerSignal('SIGTERM');
await serverPromise;
expect(logger.info).toHaveBeenCalledWith('Shutting down server...');
expect(logger.info).toHaveBeenCalledWith('Server closed');
});
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { EvalJobService } from '../../../src/server/services/evalJobService';
describe('EvalJobService', () => {
it('creates an in-progress job', () => {
const service = new EvalJobService();
expect(service.create('job-1')).toEqual({
evalId: null,
status: 'in-progress',
progress: 0,
total: 0,
result: null,
logs: [],
});
});
it('tracks progress, completion, and logs', () => {
const service = new EvalJobService();
service.create('job-1');
expect(service.setProgress('job-1', 2, 5)).toBe(true);
expect(service.appendLog('job-1', 'working')).toBe(true);
expect(service.complete('job-1', { results: [] } as never, 'eval-1')).toBe(true);
expect(service.get('job-1')).toMatchObject({
evalId: 'eval-1',
status: 'complete',
progress: 2,
total: 5,
result: { results: [] },
logs: ['working'],
});
});
it('returns defensive snapshots', () => {
const service = new EvalJobService();
service.create('job-1');
const result = { results: [{ output: { nested: 'original' } }] } as never;
service.complete('job-1', result, 'eval-1');
const snapshot = service.get('job-1');
expect(snapshot).toBeDefined();
snapshot?.logs.push('mutated outside service');
(snapshot?.result as any).results[0].output.nested = 'mutated outside service';
(result as any).results[0].output.nested = 'mutated after completion';
expect(service.get('job-1')?.logs).toEqual([]);
expect((service.get('job-1')?.result as any).results[0].output.nested).toBe('original');
});
it('stores JSON-safe snapshots for function-backed prompts', () => {
const service = new EvalJobService();
service.create('job-1');
expect(() =>
service.complete(
'job-1',
{
prompts: [{ function: () => 'generated prompt', label: 'dynamic prompt' }],
results: [],
} as never,
'eval-1',
),
).not.toThrow();
expect(service.get('job-1')?.result).toEqual({
prompts: [{ label: 'dynamic prompt' }],
results: [],
});
});
it('stores JSON-safe snapshots for circular references and bigint values', () => {
const service = new EvalJobService();
service.create('job-1');
const output: { count: bigint; self?: unknown } = { count: 42n };
output.self = output;
expect(() =>
service.complete(
'job-1',
{
results: [{ response: { output } }],
} as never,
'eval-1',
),
).not.toThrow();
expect(service.get('job-1')?.result).toEqual({
results: [{ response: { output: { count: '42' } } }],
});
});
it('supports replacing and appending failure logs', () => {
const service = new EvalJobService();
service.create('job-1');
service.appendLog('job-1', 'before failure');
expect(service.fail('job-1', ['first failure'])).toBe(true);
expect(service.get('job-1')?.logs).toEqual(['first failure']);
expect(service.fail('job-1', ['second failure'], { append: true })).toBe(true);
expect(service.get('job-1')).toMatchObject({
evalId: null,
status: 'error',
result: null,
logs: ['first failure', 'second failure'],
});
});
it('can preserve completed results when marking a job as failed', () => {
const service = new EvalJobService();
service.create('job-1');
service.complete('job-1', { results: [] } as never, 'eval-1');
expect(
service.fail('job-1', ['cancelled after completion'], {
append: true,
resetResult: false,
}),
).toBe(true);
expect(service.get('job-1')).toMatchObject({
evalId: 'eval-1',
status: 'error',
result: { results: [] },
logs: ['cancelled after completion'],
});
});
it('returns false when updating a missing job', () => {
const service = new EvalJobService();
expect(service.setProgress('missing', 1, 1)).toBe(false);
expect(service.complete('missing', null, null)).toBe(false);
expect(service.fail('missing', ['missing'])).toBe(false);
expect(service.appendLog('missing', 'missing')).toBe(false);
});
});
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PromptCacheService } from '../../../src/server/services/promptCacheService';
import { getPrompts } from '../../../src/util/database';
import { createDeferred } from '../../util/utils';
vi.mock('../../../src/util/database', () => ({
getPrompts: vi.fn(),
}));
describe('PromptCacheService', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('caches prompts until invalidated', async () => {
vi.mocked(getPrompts)
.mockResolvedValueOnce([{ id: 'first' }] as never)
.mockResolvedValueOnce([{ id: 'second' }] as never);
const service = new PromptCacheService();
expect(await service.getAll()).toEqual([{ id: 'first' }]);
expect(await service.getAll()).toEqual([{ id: 'first' }]);
expect(getPrompts).toHaveBeenCalledOnce();
service.invalidate();
expect(await service.getAll()).toEqual([{ id: 'second' }]);
expect(getPrompts).toHaveBeenCalledTimes(2);
});
it('does not cache a load that resolves after invalidation', async () => {
const pending = createDeferred<Awaited<ReturnType<typeof getPrompts>>>();
vi.mocked(getPrompts)
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce([{ id: 'fresh' }] as never);
const service = new PromptCacheService();
const staleRequest = service.getAll();
service.invalidate();
pending.resolve([{ id: 'stale' }] as never);
await expect(staleRequest).resolves.toEqual([{ id: 'stale' }]);
await expect(service.getAll()).resolves.toEqual([{ id: 'fresh' }]);
expect(getPrompts).toHaveBeenCalledTimes(2);
});
it('retries after a rejected load', async () => {
vi.mocked(getPrompts)
.mockRejectedValueOnce(new Error('database unavailable'))
.mockResolvedValueOnce([{ id: 'fresh' }] as never);
const service = new PromptCacheService();
await expect(service.getAll()).rejects.toThrow('database unavailable');
await expect(service.getAll()).resolves.toEqual([{ id: 'fresh' }]);
expect(getPrompts).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMockResponse } from '../../util/utils';
import type { MultiTurnPromptParams } from '../../../src/server/services/redteamTestCaseGenerationService';
async function getExpectedRemoteGenerationUrl() {
const { getRemoteGenerationUrl } = await vi.importActual<
typeof import('../../../src/redteam/remoteGeneration')
>('../../../src/redteam/remoteGeneration');
return getRemoteGenerationUrl();
}
const TEST_REQUEST_TIMEOUT_MS = 300000;
const MOCKED_MODULES = [
'../../../src/util/fetch/index',
'../../../src/redteam/remoteGeneration',
'../../../src/providers/shared',
'../../../src/constants',
];
function mockRemoteGeneration(responseBody: unknown, rejectWith?: Error) {
const fetchWithRetries = rejectWith
? vi.fn().mockRejectedValueOnce(rejectWith)
: vi.fn().mockResolvedValueOnce(
createMockResponse({
body: responseBody,
}),
);
vi.doMock('../../../src/util/fetch/index', () => ({
fetchWithRetries,
}));
vi.doMock('../../../src/redteam/remoteGeneration', async () => ({
getRemoteGenerationUrl: vi.fn().mockReturnValue(await getExpectedRemoteGenerationUrl()),
getRemoteGenerationHeaders: vi.fn((extra) => ({
'Content-Type': 'application/json',
...extra,
})),
neverGenerateRemote: vi.fn().mockReturnValue(false),
}));
vi.doMock('../../../src/providers/shared', () => ({
getRequestTimeoutMs: () => TEST_REQUEST_TIMEOUT_MS,
}));
vi.doMock('../../../src/constants', () => ({
VERSION: '0.0.0-test',
}));
return fetchWithRetries;
}
async function generatePromptForStrategy(strategyId: MultiTurnPromptParams['strategyId']) {
const { generateMultiTurnPrompt } = await import(
'../../../src/server/services/redteamTestCaseGenerationService'
);
await generateMultiTurnPrompt({
pluginId: 'harmful:hate',
strategyId,
strategyConfigRecord: {},
history: [],
turn: 0,
maxTurns: 5,
baseMetadata: { pluginConfig: {} },
generatedPrompt: 'initial prompt',
purpose: 'test purpose',
});
}
async function expectTaskRequest(fetchWithRetries: ReturnType<typeof vi.fn>, expectedTask: string) {
expect(fetchWithRetries).toHaveBeenCalledTimes(1);
const [url, request, timeout] = fetchWithRetries.mock.calls[0]!;
const body = JSON.parse(String(request.body));
expect(url).toBe(await getExpectedRemoteGenerationUrl());
expect(request).toMatchObject({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(body.task).toBe(expectedTask);
expect(timeout).toBe(TEST_REQUEST_TIMEOUT_MS);
}
describe('redteamTestCaseGenerationService', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.resetAllMocks();
for (const modulePath of MOCKED_MODULES) {
vi.doUnmock(modulePath);
}
vi.resetModules();
});
describe('multi-turn strategy handlers use fetchWithRetries', () => {
it('should call fetchWithRetries with correct parameters for GOAT strategy', async () => {
const fetchWithRetries = mockRemoteGeneration({
message: { content: 'test prompt' },
tokenUsage: { total: 100 },
});
await generatePromptForStrategy('goat');
await expectTaskRequest(fetchWithRetries, 'goat');
});
it('should propagate remote generation failures', async () => {
const remoteError = new Error('remote generation failed');
const fetchWithRetries = mockRemoteGeneration(undefined, remoteError);
await expect(generatePromptForStrategy('goat')).rejects.toThrow('remote generation failed');
expect(fetchWithRetries).toHaveBeenCalledTimes(1);
});
it('should call fetchWithRetries with correct parameters for Crescendo strategy', async () => {
const fetchWithRetries = mockRemoteGeneration({
result: {
generatedQuestion: 'test question',
lastResponseSummary: 'summary',
rationaleBehindJailbreak: 'rationale',
},
});
await generatePromptForStrategy('crescendo');
await expectTaskRequest(fetchWithRetries, 'crescendo');
});
it('should call fetchWithRetries with correct parameters for Hydra strategy', async () => {
const fetchWithRetries = mockRemoteGeneration({
result: { prompt: 'test prompt' },
});
await generatePromptForStrategy('jailbreak:hydra');
await expectTaskRequest(fetchWithRetries, 'hydra-decision');
});
it('should call fetchWithRetries with correct parameters for Mischievous User strategy', async () => {
const fetchWithRetries = mockRemoteGeneration({
result: 'test prompt',
});
await generatePromptForStrategy('mischievous-user');
await expectTaskRequest(fetchWithRetries, 'mischievous-user-redteam');
});
});
});
+133
View File
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { setDownloadHeaders } from '../../../src/server/utils/downloadHelpers';
import type { Response } from 'express';
describe('downloadHelpers', () => {
describe('setDownloadHeaders', () => {
let mockRes: Partial<Response>;
let setHeaderMock: Mock;
beforeEach(() => {
setHeaderMock = vi.fn().mockReturnThis();
mockRes = {
setHeader: setHeaderMock,
} as Partial<Response>;
});
it('should set correct headers for CSV download', () => {
setDownloadHeaders(mockRes as Response, 'test-file.csv', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="test-file.csv"',
);
expect(setHeaderMock).toHaveBeenCalledWith(
'Cache-Control',
'no-cache, no-store, must-revalidate',
);
expect(setHeaderMock).toHaveBeenCalledWith('Pragma', 'no-cache');
expect(setHeaderMock).toHaveBeenCalledWith('Expires', '0');
});
it('should set correct headers for JSON download', () => {
setDownloadHeaders(mockRes as Response, 'data.json', 'application/json');
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'application/json');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="data.json"',
);
});
it('should handle special characters in filename', () => {
setDownloadHeaders(mockRes as Response, 'file with spaces & special.csv', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="file with spaces & special.csv"',
);
});
it('should handle very long filenames', () => {
const longFileName = 'a'.repeat(255) + '.csv';
setDownloadHeaders(mockRes as Response, longFileName, 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
`attachment; filename="${longFileName}"`,
);
});
it('should handle Unicode characters in filename', () => {
setDownloadHeaders(mockRes as Response, '测试文件.csv', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="测试文件.csv"',
);
});
it('should handle different content types', () => {
const testCases = [
{ fileName: 'file.pdf', contentType: 'application/pdf' },
{ fileName: 'file.xml', contentType: 'application/xml' },
{ fileName: 'file.txt', contentType: 'text/plain' },
{ fileName: 'file.yaml', contentType: 'application/x-yaml' },
];
testCases.forEach(({ fileName, contentType }) => {
setHeaderMock.mockClear();
setDownloadHeaders(mockRes as Response, fileName, contentType);
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', contentType);
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
`attachment; filename="${fileName}"`,
);
});
});
it('should set all cache control headers', () => {
setDownloadHeaders(mockRes as Response, 'test.csv', 'text/csv');
// Verify all cache-preventing headers are set
const calls = setHeaderMock.mock.calls;
const headerMap = new Map(calls.map((call) => [call[0] as string, call[1]]));
expect(headerMap.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
expect(headerMap.get('Pragma')).toBe('no-cache');
expect(headerMap.get('Expires')).toBe('0');
});
it('should be called exactly 5 times for all headers', () => {
setDownloadHeaders(mockRes as Response, 'test.csv', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledTimes(5);
});
it('should handle empty filename', () => {
setDownloadHeaders(mockRes as Response, '', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename=""');
});
it('should handle filename with quotes', () => {
setDownloadHeaders(mockRes as Response, 'file"with"quotes.csv', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="file"with"quotes.csv"',
);
});
it('should handle filename with path separators', () => {
setDownloadHeaders(mockRes as Response, 'path/to/file.csv', 'text/csv');
expect(setHeaderMock).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="path/to/file.csv"',
);
});
});
});
+289
View File
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { z } from 'zod';
import logger from '../../../src/logger';
import { replyValidationError, sendError } from '../../../src/server/utils/errors';
import type { Response } from 'express';
vi.mock('../../../src/logger', () => ({
default: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
function createResponseMock(): {
res: Response;
status: Mock;
json: Mock;
} {
const json = vi.fn().mockReturnThis();
const status = vi.fn(() => ({ json }));
return {
res: { status, json } as unknown as Response,
status: status as unknown as Mock,
json,
};
}
describe('server/utils/errors', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('sendError', () => {
it('returns the public message and never leaks the internal error', () => {
const { res, status, json } = createResponseMock();
sendError(res, 500, 'Failed to do thing', new Error('secret db password leaked'));
expect(status).toHaveBeenCalledWith(500);
const body = json.mock.calls[0]?.[0];
expect(body).toEqual({ error: 'Failed to do thing' });
expect(JSON.stringify(body)).not.toContain('secret db password');
});
it('skips logging when no internal error is provided', () => {
const { res } = createResponseMock();
sendError(res, 404, 'Not found');
expect(logger.error).not.toHaveBeenCalled();
});
it('logs Error name, message, and stack as a structured object', () => {
const { res } = createResponseMock();
const err = new Error('boom');
err.stack = 'Error: boom\n at someFn';
sendError(res, 500, 'Failed', err);
expect(logger.error).toHaveBeenCalledTimes(1);
const [message, context] = (logger.error as unknown as Mock).mock.calls[0];
expect(message).toBe('Failed');
expect(context).toEqual({
error: {
name: 'Error',
message: 'boom',
stack: 'Error: boom\n at someFn',
},
});
});
it('preserves Error.cause when present, serialized to retain its stack', () => {
const { res } = createResponseMock();
const cause = new Error('underlying');
cause.stack = 'Error: underlying\n at someInnerFn';
const err = new Error('wrapper', { cause });
sendError(res, 500, 'Failed', err);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const ctx = context as { error: { cause: { name: string; message: string; stack: string } } };
expect(ctx.error.cause.name).toBe('Error');
expect(ctx.error.cause.message).toBe('underlying');
// Stack must survive — serializing the raw Error reference would have
// dropped it via JSON.stringify, which is the regression this helper exists to prevent.
expect(ctx.error.cause.stack).toContain('someInnerFn');
});
it('passes non-Error values through verbatim', () => {
const { res } = createResponseMock();
sendError(res, 500, 'Failed', 'plain string error');
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
expect(context).toEqual({ error: 'plain string error' });
});
it('logs `null` as a real (no-detail) failure rather than skipping', () => {
// Pin current behavior: the guard is `internalError !== undefined`, so
// `null` reaches the logger. This is intentional — a future refactor to
// `if (internalError)` would silently swallow falsy-but-real signals
// (e.g. `0`, `false`, `''`).
const { res } = createResponseMock();
sendError(res, 500, 'Failed', null);
expect(logger.error).toHaveBeenCalledTimes(1);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
expect(context).toEqual({ error: null });
});
it('preserves enumerable diagnostic fields on Error subclasses', () => {
// Node SystemError carries `code`/`errno`/`syscall`/`path` as enumerable
// own props; AWS SDK errors carry `$metadata`/`$fault`; fetch errors
// carry `code`/`errno`. Logging the explicit four fields without
// spreading would silently drop these — exactly the production-triage
// regression flagged by the Codex P2 reviewer on this PR.
const { res } = createResponseMock();
const sysErr = Object.assign(new Error('ENOENT: no such file or directory, open ...'), {
code: 'ENOENT',
errno: -2,
syscall: 'open',
path: '/missing/file',
});
sendError(res, 500, 'Failed', sysErr);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const ctx = context as {
error: {
name: string;
message: string;
stack: string;
code: string;
errno: number;
syscall: string;
path: string;
};
};
expect(ctx.error.name).toBe('Error');
expect(ctx.error.code).toBe('ENOENT');
expect(ctx.error.errno).toBe(-2);
expect(ctx.error.syscall).toBe('open');
expect(ctx.error.path).toBe('/missing/file');
// Standard fields still win even though the subclass also has its own.
expect(typeof ctx.error.stack).toBe('string');
});
it('preserves Error subclass name (TypeError, custom subclasses)', () => {
const { res } = createResponseMock();
class CustomError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomError';
}
}
sendError(res, 500, 'Failed', new TypeError('not a string'));
sendError(res, 500, 'Failed', new CustomError('bespoke'));
const calls = (logger.error as unknown as Mock).mock.calls;
expect((calls[0][1] as { error: { name: string } }).error.name).toBe('TypeError');
expect((calls[1][1] as { error: { name: string } }).error.name).toBe('CustomError');
});
it('recursively unwraps Error.cause chains so nested stacks survive serialization', () => {
const { res } = createResponseMock();
const root = new Error('root cause');
root.stack = 'Error: root cause\n at deep';
const middle = new Error('middle', { cause: root });
middle.stack = 'Error: middle\n at middle';
const top = new Error('top', { cause: middle });
top.stack = 'Error: top\n at top';
sendError(res, 500, 'Failed', top);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const ctx = context as {
error: {
stack: string;
cause: { stack: string; cause: { stack: string } };
};
};
expect(ctx.error.stack).toContain('top');
expect(ctx.error.cause.stack).toContain('middle');
expect(ctx.error.cause.cause.stack).toContain('deep');
});
it('does not crash on a circular Error.cause', () => {
const { res, status } = createResponseMock();
const cyclic = new Error('cyclic') as Error & { cause?: unknown };
cyclic.cause = cyclic;
expect(() => sendError(res, 500, 'Failed', cyclic)).not.toThrow();
// The response must still ship — cycle detection in serializeError is
// the entire reason this canonical 500 funnel doesn't unhandled-throw.
expect(status).toHaveBeenCalledWith(500);
});
it('caps long Error.cause chains before serializing unbounded logger context', () => {
const { res } = createResponseMock();
const chain = Array.from({ length: 6 }, (_, index) => {
const err = new Error(`level-${index}`) as Error & { cause?: unknown };
err.stack = `Error: level-${index}\n at level${index}`;
return err;
});
for (let i = 0; i < chain.length - 1; i++) {
chain[i].cause = chain[i + 1];
}
sendError(res, 500, 'Failed', chain[0]);
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
const cappedCause = (
context as {
error: { cause: { cause: { cause: { cause: unknown } } } };
}
).error.cause.cause.cause.cause;
expect(cappedCause).toEqual({ name: 'Error', message: 'level-4' });
expect(JSON.stringify(context)).not.toContain('level-5');
});
it('falls back to a hand-built envelope if ErrorResponseSchema.parse throws', async () => {
// Force the schema parse to throw, simulating a future tightening that
// rejects what `sendError` produces. Without the safeRespond fallback,
// the throw would escape the handler's try/catch and land in Express's
// default error path (empty 500 with no JSON body).
const common = await import('../../../src/types/api/common');
const parseSpy = vi.spyOn(common.ErrorResponseSchema, 'parse').mockImplementationOnce(() => {
throw new Error('forced schema rejection');
});
try {
const { res, status, json } = createResponseMock();
expect(() => sendError(res, 500, 'Public message')).not.toThrow();
expect(status).toHaveBeenCalledWith(500);
expect(json.mock.calls[0]?.[0]).toEqual({ error: 'Public message' });
expect(parseSpy).toHaveBeenCalledTimes(1);
} finally {
parseSpy.mockRestore();
}
});
});
describe('replyValidationError', () => {
let zodError: z.ZodError;
beforeEach(() => {
const schema = z.object({
name: z.string(),
age: z.number().int().nonnegative(),
});
const result = schema.safeParse({ name: 42, age: -1 });
if (result.success) {
throw new Error('expected parse to fail');
}
zodError = result.error;
});
it('returns a 400 with prettified message in `error`', () => {
const { res, status, json } = createResponseMock();
replyValidationError(res, zodError);
expect(status).toHaveBeenCalledWith(400);
const body = json.mock.calls[0]?.[0] as { error: string };
expect(typeof body.error).toBe('string');
expect(body.error.length).toBeGreaterThan(0);
});
it('exposes structured Zod issues in `details` for programmatic clients', () => {
const { res, json } = createResponseMock();
replyValidationError(res, zodError);
const body = json.mock.calls[0]?.[0] as {
details: { issues: z.core.$ZodIssue[] };
};
expect(Array.isArray(body.details.issues)).toBe(true);
expect(body.details.issues.length).toBe(zodError.issues.length);
const paths = body.details.issues.map((issue) => issue.path.join('.'));
expect(paths).toContain('name');
expect(paths).toContain('age');
});
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import * as legacyEvalTableUtils from '../../../src/server/utils/evalTableUtils';
import * as evalTableUtils from '../../../src/util/eval/evalTableUtils';
describe('legacy evalTableUtils import', () => {
it('re-exports the node-layer implementation for enterprise compatibility', () => {
expect(legacyEvalTableUtils.evalTableToCsv).toBe(evalTableUtils.evalTableToCsv);
expect(legacyEvalTableUtils.streamEvalCsv).toBe(evalTableUtils.streamEvalCsv);
expect(legacyEvalTableUtils.mergeComparisonTables).toBe(evalTableUtils.mergeComparisonTables);
});
});
+507
View File
@@ -0,0 +1,507 @@
{
"data": {
"version": 3,
"createdAt": "2024-10-02T16:30:21.627Z",
"author": "steve@promptfoo.dev",
"results": {
"version": 2,
"timestamp": "2024-10-02T16:29:47.070Z",
"results": [
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Translate this to conversational French: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"vars": { "language": "French", "body": "I'm hungry" },
"response": {
"output": "J'ai faim.",
"tokenUsage": { "cached": 19, "total": 19 },
"cached": true,
"cost": 0.0000046499999999999995
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 2,
"cost": 0.0000046499999999999995,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Rephrase this in French: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"vars": { "language": "French", "body": "I'm hungry" },
"response": {
"output": "J'ai faim.",
"tokenUsage": { "cached": 19, "total": 19 },
"cached": true,
"cost": 0.0000046499999999999995
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 4,
"cost": 0.0000046499999999999995,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Rephrase this in French: Hello world",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"vars": { "language": "French", "body": "Hello world" },
"response": {
"output": "Bonjour le monde.",
"tokenUsage": { "cached": 19, "total": 19 },
"cached": true,
"cost": 0.0000046499999999999995
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 6,
"cost": 0.0000046499999999999995,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Translate this to conversational French: Hello world",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"vars": { "language": "French", "body": "Hello world" },
"response": {
"output": "Salut tout le monde !",
"tokenUsage": { "cached": 20, "total": 20 },
"cached": true,
"cost": 0.0000052500000000000006
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 6,
"cost": 0.0000052500000000000006,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Rephrase this in Pirate: Hello world",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"vars": { "language": "Pirate", "body": "Hello world" },
"response": {
"output": "Ahoy, me hearties!",
"tokenUsage": { "cached": 22, "total": 22 },
"cached": true,
"cost": 0.000006449999999999999
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 3,
"cost": 0.000006449999999999999,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Translate this to conversational Pirate: Hello world",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"vars": { "language": "Pirate", "body": "Hello world" },
"response": {
"output": "Ahoy, me hearty! Greetings to ye, world!",
"tokenUsage": { "cached": 27, "total": 27 },
"cached": true,
"cost": 0.00000945
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 3,
"cost": 0.00000945,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Rephrase this in Pirate: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"vars": { "language": "Pirate", "body": "I'm hungry" },
"response": {
"output": "Arrr, me belly be grumblin'!",
"tokenUsage": { "cached": 27, "total": 27 },
"cached": true,
"cost": 0.00000945
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 2,
"cost": 0.00000945,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
},
{
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
"prompt": {
"raw": "Translate this to conversational Pirate: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"vars": { "language": "Pirate", "body": "I'm hungry" },
"response": {
"output": "Arrr, me belly be rumblin'! I be feelin' mighty hungry, matey!",
"tokenUsage": { "cached": 37, "total": 37 },
"cached": true,
"cost": 0.00001545
},
"success": true,
"score": 1,
"namedScores": {},
"latencyMs": 1,
"cost": 0.00001545,
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
}
}
],
"stats": {
"successes": 8,
"failures": 0,
"tokenUsage": { "total": 190, "prompt": 0, "completion": 0, "cached": 190 }
},
"table": {
"head": {
"prompts": [
{
"raw": "Rephrase this in {{language}}: {{body}}",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}",
"id": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
"provider": "openai:gpt-4o-mini",
"metrics": {
"score": 4,
"testPassCount": 4,
"testFailCount": 0,
"assertPassCount": 0,
"assertFailCount": 0,
"totalLatencyMs": 15,
"tokenUsage": { "total": 87, "prompt": 0, "completion": 0, "cached": 87 },
"namedScores": {},
"namedScoresCount": {},
"cost": 0.000025199999999999996
}
},
{
"raw": "Translate this to conversational {{language}}: {{body}}",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}",
"id": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
"provider": "openai:gpt-4o-mini",
"metrics": {
"score": 4,
"testPassCount": 4,
"testFailCount": 0,
"assertPassCount": 0,
"assertFailCount": 0,
"totalLatencyMs": 12,
"tokenUsage": { "total": 103, "prompt": 0, "completion": 0, "cached": 103 },
"namedScores": {},
"namedScoresCount": {},
"cost": 0.0000348
}
}
],
"vars": ["body", "language"]
},
"body": [
{
"description": "Row #1",
"outputs": [
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "Bonjour le monde.",
"prompt": "Rephrase this in French: Hello world",
"provider": "openai:gpt-4o-mini",
"latencyMs": 6,
"tokenUsage": { "cached": 19, "total": 19 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.0000046499999999999995
},
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "Salut tout le monde !",
"prompt": "Translate this to conversational French: Hello world",
"provider": "openai:gpt-4o-mini",
"latencyMs": 6,
"tokenUsage": { "cached": 20, "total": 20 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.0000052500000000000006
}
],
"test": {
"vars": { "language": "French", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #1",
"metadata": {}
},
"vars": ["Hello world", "French"]
},
{
"description": "Row #2",
"outputs": [
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "J'ai faim.",
"prompt": "Rephrase this in French: I'm hungry",
"provider": "openai:gpt-4o-mini",
"latencyMs": 4,
"tokenUsage": { "cached": 19, "total": 19 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.0000046499999999999995
},
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "J'ai faim.",
"prompt": "Translate this to conversational French: I'm hungry",
"provider": "openai:gpt-4o-mini",
"latencyMs": 2,
"tokenUsage": { "cached": 19, "total": 19 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.0000046499999999999995
}
],
"test": {
"vars": { "language": "French", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #2",
"metadata": {}
},
"vars": ["I'm hungry", "French"]
},
{
"description": "Row #3",
"outputs": [
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "Ahoy, me hearties!",
"prompt": "Rephrase this in Pirate: Hello world",
"provider": "openai:gpt-4o-mini",
"latencyMs": 3,
"tokenUsage": { "cached": 22, "total": 22 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.000006449999999999999
},
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "Ahoy, me hearty! Greetings to ye, world!",
"prompt": "Translate this to conversational Pirate: Hello world",
"provider": "openai:gpt-4o-mini",
"latencyMs": 3,
"tokenUsage": { "cached": 27, "total": 27 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.00000945
}
],
"test": {
"vars": { "language": "Pirate", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #3",
"metadata": {}
},
"vars": ["Hello world", "Pirate"]
},
{
"description": "Row #4",
"outputs": [
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "Arrr, me belly be grumblin'!",
"prompt": "Rephrase this in Pirate: I'm hungry",
"provider": "openai:gpt-4o-mini",
"latencyMs": 2,
"tokenUsage": { "cached": 27, "total": 27 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.00000945
},
{
"pass": true,
"score": 1,
"namedScores": {},
"text": "Arrr, me belly be rumblin'! I be feelin' mighty hungry, matey!",
"prompt": "Translate this to conversational Pirate: I'm hungry",
"provider": "openai:gpt-4o-mini",
"latencyMs": 1,
"tokenUsage": { "cached": 37, "total": 37 },
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"cost": 0.00001545
}
],
"test": {
"vars": { "language": "Pirate", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #4",
"metadata": {}
},
"vars": ["I'm hungry", "Pirate"]
}
]
}
},
"config": {
"tags": {},
"description": "A translator built with LLM",
"prompts": ["file:///Users/steve/src/promptfoo/examples/simple-csv/prompts.txt"],
"providers": ["openai:gpt-4o-mini"],
"tests": [
{
"vars": { "language": "French", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #1"
},
{
"vars": { "language": "French", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #2"
},
{
"vars": { "language": "Pirate", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #3"
},
{
"vars": { "language": "Pirate", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #4"
}
],
"scenarios": [],
"env": {},
"sharing": true,
"defaultTest": { "vars": {}, "assert": [], "options": {}, "metadata": {} },
"outputPath": [],
"extensions": [],
"metadata": {},
"redteam": { "plugins": [], "strategies": [] }
}
}
}
+421
View File
@@ -0,0 +1,421 @@
{
"createdAt": 1727905040496,
"id": "eval-2024-10-02T21:37:20-kMQ",
"config": {
"tags": {},
"description": "A translator built with LLM",
"prompts": ["file:///Users/steve/src/promptfoo/examples/simple-csv/prompts.txt"],
"providers": ["openai:gpt-4o-mini"],
"tests": [
{
"vars": { "language": "French", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #1"
},
{
"vars": { "language": "French", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #2"
},
{
"vars": { "language": "Pirate", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #3"
},
{
"vars": { "language": "Pirate", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #4"
}
],
"scenarios": [],
"env": {},
"sharing": true,
"defaultTest": { "vars": {}, "assert": [], "options": {}, "metadata": {} },
"outputPath": [],
"extensions": [],
"metadata": {},
"redteam": { "plugins": [], "strategies": [] }
},
"results": [
{
"id": "14c141a7-be6f-460f-b3cf-711e0a64fe88",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 0,
"testIdx": 0,
"testCase": {
"vars": { "language": "French", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #1",
"metadata": {}
},
"prompt": {
"raw": "Rephrase this in French: Hello world",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "Bonjour le monde.",
"tokenUsage": { "cached": 19, "total": 19 },
"cached": true,
"cost": 0.0000046499999999999995
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 3,
"cost": 0.0000046499999999999995
},
{
"id": "013a4746-4051-4dc5-87d1-1c8414bc124f",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 1,
"testIdx": 0,
"testCase": {
"vars": { "language": "French", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #1",
"metadata": {}
},
"prompt": {
"raw": "Translate this to conversational French: Hello world",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "Salut tout le monde !",
"tokenUsage": { "cached": 20, "total": 20 },
"cached": true,
"cost": 0.0000052500000000000006
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 7,
"cost": 0.0000052500000000000006
},
{
"id": "7791f530-38e0-431a-b8ef-fc2a64b14cb5",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 1,
"testIdx": 1,
"testCase": {
"vars": { "language": "French", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #2",
"metadata": {}
},
"prompt": {
"raw": "Translate this to conversational French: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "J'ai faim.",
"tokenUsage": { "cached": 19, "total": 19 },
"cached": true,
"cost": 0.0000046499999999999995
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 8,
"cost": 0.0000046499999999999995
},
{
"id": "1720b007-3e86-4920-a738-95fb0dcf2a3f",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 0,
"testIdx": 1,
"testCase": {
"vars": { "language": "French", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #2",
"metadata": {}
},
"prompt": {
"raw": "Rephrase this in French: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "J'ai faim.",
"tokenUsage": { "cached": 19, "total": 19 },
"cached": true,
"cost": 0.0000046499999999999995
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 10,
"cost": 0.0000046499999999999995
},
{
"id": "4540d2da-9a5a-402e-9243-41be20f63b1f",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 0,
"testIdx": 2,
"testCase": {
"vars": { "language": "Pirate", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #3",
"metadata": {}
},
"prompt": {
"raw": "Rephrase this in Pirate: Hello world",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "Ahoy, me hearties!",
"tokenUsage": { "cached": 22, "total": 22 },
"cached": true,
"cost": 0.000006449999999999999
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 5,
"cost": 0.000006449999999999999
},
{
"id": "467d9521-22f8-45ae-a3c2-0777dd7b1bb0",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 1,
"testIdx": 2,
"testCase": {
"vars": { "language": "Pirate", "body": "Hello world" },
"assert": [],
"options": {},
"description": "Row #3",
"metadata": {}
},
"prompt": {
"raw": "Translate this to conversational Pirate: Hello world",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "Ahoy, me hearty! Greetings to ye, world!",
"tokenUsage": { "cached": 27, "total": 27 },
"cached": true,
"cost": 0.00000945
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 5,
"cost": 0.00000945
},
{
"id": "70b1ebd1-f748-4969-9d38-b6460ff6107f",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 0,
"testIdx": 3,
"testCase": {
"vars": { "language": "Pirate", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #4",
"metadata": {}
},
"prompt": {
"raw": "Rephrase this in Pirate: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
},
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "Arrr, me belly be grumblin'!",
"tokenUsage": { "cached": 27, "total": 27 },
"cached": true,
"cost": 0.00000945
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 3,
"cost": 0.00000945
},
{
"id": "5b65ede9-f787-4637-b18d-9e0ff69dd778",
"evalId": "eval-2024-10-02T21:37:20-kMQ",
"promptIdx": 1,
"testIdx": 3,
"testCase": {
"vars": { "language": "Pirate", "body": "I'm hungry" },
"assert": [],
"options": {},
"description": "Row #4",
"metadata": {}
},
"prompt": {
"raw": "Translate this to conversational Pirate: I'm hungry",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
},
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
"error": null,
"score": 1,
"success": true,
"providerResponse": {
"output": "Arrr, me belly be rumblin'! I be feelin' mighty hungry, matey!",
"tokenUsage": { "cached": 37, "total": 37 },
"cached": true,
"cost": 0.00001545
},
"gradingResult": {
"pass": true,
"score": 1,
"reason": "No assertions",
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
"assertion": null
},
"namedScores": {},
"provider": {
"id": "openai:gpt-4o-mini",
"label": "",
"config": { "basePath": "examples/simple-csv" }
},
"latencyMs": 4,
"cost": 0.00001545
}
],
"prompts": [
{
"raw": "Rephrase this in {{language}}: {{body}}",
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}",
"id": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
"provider": "openai:gpt-4o-mini",
"metrics": {
"score": 4,
"testPassCount": 4,
"testFailCount": 0,
"assertPassCount": 0,
"assertFailCount": 0,
"totalLatencyMs": 21,
"tokenUsage": { "total": 87, "prompt": 0, "completion": 0, "cached": 87 },
"namedScores": {},
"namedScoresCount": {},
"cost": 0.000025199999999999996
}
},
{
"raw": "Translate this to conversational {{language}}: {{body}}",
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}",
"id": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
"provider": "openai:gpt-4o-mini",
"metrics": {
"score": 4,
"testPassCount": 4,
"testFailCount": 0,
"assertPassCount": 0,
"assertFailCount": 0,
"totalLatencyMs": 24,
"tokenUsage": { "total": 103, "prompt": 0, "completion": 0, "cached": 103 },
"namedScores": {},
"namedScoresCount": {},
"cost": 0.0000348
}
}
],
"datasetId": "27018c2ea4e47d8af2fc1b2d69c131ed777f1d89e9069bf38622701b7e1e786a"
}