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

459 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../src/cache';
import { cloudConfig } from '../src/globalConfig/cloud';
import guardrails, { type AdaptiveRequest } from '../src/guardrails';
vi.mock('../src/cache', () => ({
fetchWithCache: vi.fn(),
}));
vi.mock('../src/globalConfig/cloud', () => ({
cloudConfig: {
isEnabled: vi.fn(),
getApiHost: vi.fn(),
getApiKey: vi.fn(),
},
}));
describe('guardrails', () => {
const mockFetchResponse = {
data: {
model: 'test-model',
results: [
{
categories: {
test_category: true,
},
category_scores: {
test_category: 0.95,
},
flagged: true,
},
],
},
cached: false,
status: 200,
statusText: 'OK',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchWithCache).mockResolvedValue(mockFetchResponse);
// Default to the non-cloud path so existing assertions stay deterministic
// regardless of the local 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);
});
describe('guard', () => {
it('should make a request to the guard endpoint', async () => {
const input = 'test input';
await guardrails.guard(input);
expect(fetchWithCache).toHaveBeenCalledWith(
expect.stringContaining('/v1/guard'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ input }),
},
undefined,
'json',
);
});
it('should return the parsed guard result', async () => {
const result = await guardrails.guard('test input');
expect(result).toEqual(mockFetchResponse.data);
});
it('should handle API errors', async () => {
const errorMessage = 'API Error';
vi.mocked(fetchWithCache).mockRejectedValue(new Error(errorMessage));
await expect(guardrails.guard('test input')).rejects.toThrow(errorMessage);
});
it('should handle empty API response', async () => {
vi.mocked(fetchWithCache).mockResolvedValue({
data: null,
cached: false,
status: 200,
statusText: 'OK',
});
await expect(guardrails.guard('test input')).rejects.toThrow('No data returned from API');
});
});
describe('pii', () => {
const mockPiiResponse = {
data: {
model: 'test-model',
results: [
{
categories: {
pii: true,
},
category_scores: {
pii: 1,
},
flagged: true,
payload: {
pii: [
{
entity_type: 'EMAIL',
start: 0,
end: 17,
pii: 'test@example.com',
},
],
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
};
beforeEach(() => {
vi.mocked(fetchWithCache).mockResolvedValue(mockPiiResponse);
});
it('should make a request to the pii endpoint', async () => {
const input = 'test@example.com';
await guardrails.pii(input);
expect(fetchWithCache).toHaveBeenCalledWith(
expect.stringContaining('/v1/pii'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ input }),
},
undefined,
'json',
);
});
it('should return the parsed PII result', async () => {
const result = await guardrails.pii('test@example.com');
expect(result).toEqual(mockPiiResponse.data);
expect(result.results[0].payload?.pii?.[0].entity_type).toBe('EMAIL');
});
it('should handle API errors', async () => {
const errorMessage = 'API Error';
vi.mocked(fetchWithCache).mockRejectedValue(new Error(errorMessage));
await expect(guardrails.pii('test input')).rejects.toThrow(errorMessage);
});
});
describe('harm', () => {
const mockHarmResponse = {
data: {
model: 'test-model',
results: [
{
categories: {
hate: true,
violent_crimes: false,
},
category_scores: {
hate: 0.95,
violent_crimes: 0.1,
},
flagged: true,
},
],
},
cached: false,
status: 200,
statusText: 'OK',
};
beforeEach(() => {
vi.mocked(fetchWithCache).mockResolvedValue(mockHarmResponse);
});
it('should make a request to the harm endpoint', async () => {
const input = 'test input';
await guardrails.harm(input);
expect(fetchWithCache).toHaveBeenCalledWith(
expect.stringContaining('/v1/harm'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ input }),
},
undefined,
'json',
);
});
it('should return the parsed harm result', async () => {
const result = await guardrails.harm('test input');
expect(result).toEqual(mockHarmResponse.data);
expect(result.results[0].categories.hate).toBe(true);
expect(result.results[0].category_scores.hate).toBe(0.95);
});
it('should handle API errors', async () => {
const errorMessage = 'API Error';
vi.mocked(fetchWithCache).mockRejectedValue(new Error(errorMessage));
await expect(guardrails.harm('test input')).rejects.toThrow(errorMessage);
});
});
describe('response structure', () => {
it('should have correct guard result structure', async () => {
const result = await guardrails.guard('test input');
expect(result).toHaveProperty('model');
expect(result).toHaveProperty('results');
expect(Array.isArray(result.results)).toBe(true);
expect(result.results[0]).toHaveProperty('categories');
expect(result.results[0]).toHaveProperty('category_scores');
expect(result.results[0]).toHaveProperty('flagged');
expect(typeof result.results[0].flagged).toBe('boolean');
});
it('should have correct PII result structure with payload', async () => {
vi.mocked(fetchWithCache).mockResolvedValue({
data: {
model: 'test-model',
results: [
{
categories: {
pii: true,
},
category_scores: {
pii: 1,
},
flagged: true,
payload: {
pii: [
{
entity_type: 'EMAIL',
start: 0,
end: 17,
pii: 'test@example.com',
},
],
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
});
const result = await guardrails.pii('test input');
expect(result).toHaveProperty('model');
expect(result).toHaveProperty('results');
expect(Array.isArray(result.results)).toBe(true);
expect(result.results[0]).toHaveProperty('payload');
expect(Array.isArray(result.results[0].payload?.pii)).toBe(true);
const piiEntity = result.results[0].payload?.pii?.[0];
expect(piiEntity).toBeDefined();
expect(piiEntity).toEqual(
expect.objectContaining({
entity_type: expect.any(String),
start: expect.any(Number),
end: expect.any(Number),
pii: expect.any(String),
}),
);
});
});
describe('adaptive function', () => {
it('should call fetchWithCache with correct parameters', async () => {
const mockResponse = {
data: {
model: 'promptfoo-adaptive-prompt',
adaptedPrompt: 'Adapted test input',
modifications: [
{
type: 'substitution',
reason: 'Policy compliance',
original: 'test input',
modified: 'Adapted test input',
},
],
},
cached: false,
status: 200,
statusText: 'OK',
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const request: AdaptiveRequest = {
prompt: 'test input',
policies: ['No harmful content'],
};
const result = await guardrails.adaptive(request);
expect(fetchWithCache).toHaveBeenCalledWith(
'https://api.promptfoo.app/v1/adaptive',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'test input',
policies: ['No harmful content'],
}),
},
undefined,
'json',
);
expect(result).toEqual(mockResponse.data);
});
it('should handle missing policies parameter', async () => {
const mockResponse = {
data: {
model: 'promptfoo-adaptive-prompt',
adaptedPrompt: 'Adapted test input',
modifications: [],
},
cached: false,
status: 200,
statusText: 'OK',
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const request: AdaptiveRequest = {
prompt: 'test input',
};
const result = await guardrails.adaptive(request);
expect(fetchWithCache).toHaveBeenCalledWith(
'https://api.promptfoo.app/v1/adaptive',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'test input',
policies: [],
}),
},
undefined,
'json',
);
expect(result).toEqual(mockResponse.data);
});
});
describe('on-prem / cloud-enabled host resolution', () => {
const ONPREM_HOST = 'https://onprem.example.com';
const ONPREM_KEY = 'test-onprem-key';
beforeEach(() => {
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
vi.mocked(cloudConfig.getApiHost).mockReturnValue(ONPREM_HOST);
vi.mocked(cloudConfig.getApiKey).mockReturnValue(ONPREM_KEY);
});
it.each([
['guard', () => guardrails.guard('test input'), '/v1/guard'],
['pii', () => guardrails.pii('test input'), '/v1/pii'],
['harm', () => guardrails.harm('test input'), '/v1/harm'],
])('routes %s to the configured cloud host with a bearer token', async (_name, call, path) => {
await call();
expect(fetchWithCache).toHaveBeenCalledWith(
`${ONPREM_HOST}${path}`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: `Bearer ${ONPREM_KEY}` }),
}),
undefined,
'json',
);
const [calledUrl] = vi.mocked(fetchWithCache).mock.calls[0];
expect(calledUrl).not.toContain('api.promptfoo.app');
});
it('routes adaptive requests to the configured cloud host with a bearer token', async () => {
vi.mocked(fetchWithCache).mockResolvedValue({
data: { model: 'm', adaptedPrompt: 'a', modifications: [] },
cached: false,
status: 200,
statusText: 'OK',
});
await guardrails.adaptive({ prompt: 'test input', policies: ['No harmful content'] });
expect(fetchWithCache).toHaveBeenCalledWith(
`${ONPREM_HOST}/v1/adaptive`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: `Bearer ${ONPREM_KEY}` }),
}),
undefined,
'json',
);
const [calledUrl] = vi.mocked(fetchWithCache).mock.calls[0];
expect(calledUrl).not.toContain('api.promptfoo.app');
});
it('strips a trailing slash on the configured host (no //v1)', async () => {
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://onprem.example.com/');
await guardrails.guard('test input');
const [calledUrl] = vi.mocked(fetchWithCache).mock.calls[0];
expect(calledUrl).toBe('https://onprem.example.com/v1/guard');
});
it('falls back to the public share host (no auth) when cloud is not enabled', async () => {
vi.mocked(cloudConfig.isEnabled).mockReturnValue(false);
await guardrails.guard('test input');
const [calledUrl, opts] = vi.mocked(fetchWithCache).mock.calls[0];
expect(calledUrl).toBe('https://api.promptfoo.app/v1/guard');
// getApiHost() must not be consulted on the non-cloud path, and no token is sent.
expect(cloudConfig.getApiHost).not.toHaveBeenCalled();
expect((opts?.headers as Record<string, string>)?.Authorization).toBeUndefined();
});
it('lets an explicit PROMPTFOO_REMOTE_API_BASE_URL override win, without leaking the token', async () => {
// Regression guard: logged-in users who redirect guardrails to a private
// endpoint must keep that override (codex P2 on the original PR). The cloud
// bearer token must NOT be sent to the override host.
vi.stubEnv('PROMPTFOO_REMOTE_API_BASE_URL', 'https://guardrails-override.example.com');
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
vi.mocked(cloudConfig.getApiHost).mockReturnValue(ONPREM_HOST);
try {
await guardrails.guard('test input');
const [calledUrl, opts] = vi.mocked(fetchWithCache).mock.calls[0];
expect(calledUrl).toBe('https://guardrails-override.example.com/v1/guard');
expect(calledUrl).not.toContain('onprem.example.com');
expect((opts?.headers as Record<string, string>)?.Authorization).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
});
});
});