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

563 lines
20 KiB
TypeScript

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