chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user