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
7518 lines
272 KiB
TypeScript
7518 lines
272 KiB
TypeScript
import { execFile, execFileSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
|
|
import * as yaml from 'js-yaml';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const repoRoot = path.resolve(__dirname, '../..');
|
|
const pluginRoot = path.join(repoRoot, 'plugins', 'promptfoo');
|
|
const repoClaudeSkillsRoot = path.join(repoRoot, '.claude', 'skills');
|
|
const repoCodexSkillsRoot = path.join(repoRoot, '.agents', 'skills');
|
|
const evalsSkillRoot = path.join(pluginRoot, 'skills', 'promptfoo-evals');
|
|
const providerSkillRoot = path.join(pluginRoot, 'skills', 'promptfoo-provider-setup');
|
|
const redteamSetupSkillRoot = path.join(pluginRoot, 'skills', 'promptfoo-redteam-setup');
|
|
const redteamRunSkillRoot = path.join(pluginRoot, 'skills', 'promptfoo-redteam-run');
|
|
const fixtureRoot = path.join(repoRoot, 'test', 'fixtures', 'agent-skills');
|
|
const expectedSkillDirs = [
|
|
'promptfoo-evals',
|
|
'promptfoo-provider-setup',
|
|
'promptfoo-redteam-run',
|
|
'promptfoo-redteam-setup',
|
|
];
|
|
const expectedPluginVersion = '0.1.1';
|
|
const expectedFixtureDirs = [
|
|
'evals-json-rubric',
|
|
'evals-local-js',
|
|
'evals-local-python',
|
|
'provider-setup-http',
|
|
'provider-setup-http-get',
|
|
'provider-setup-http-openai-compatible',
|
|
'provider-setup-http-text',
|
|
'provider-setup-local-js',
|
|
'provider-setup-local-python',
|
|
'provider-setup-openapi',
|
|
'provider-setup-redteam-target',
|
|
'redteam-run-local-error',
|
|
'redteam-run-local-mixed',
|
|
'redteam-run-local-pass',
|
|
'redteam-run-local-python-pass',
|
|
'redteam-setup-live-http',
|
|
'redteam-setup-multi-input',
|
|
'redteam-setup-single-input',
|
|
'redteam-setup-static-code',
|
|
'redteam-setup-static-code-python',
|
|
];
|
|
const redteamRunFixtureDirs = [
|
|
'redteam-run-local-pass',
|
|
'redteam-run-local-python-pass',
|
|
'redteam-run-local-mixed',
|
|
'redteam-run-local-error',
|
|
] as const;
|
|
const fixturePythonExecutable =
|
|
process.env.PROMPTFOO_PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
|
|
|
type MarketplacePlugin = {
|
|
name: string;
|
|
source: { source: string; path: string };
|
|
policy: { installation: string; authentication: string };
|
|
category: string;
|
|
};
|
|
|
|
type OpenAiYaml = {
|
|
interface: {
|
|
display_name: string;
|
|
short_description: string;
|
|
default_prompt: string;
|
|
};
|
|
policy: {
|
|
allow_implicit_invocation: boolean;
|
|
};
|
|
};
|
|
|
|
type PromptfooFixtureConfig = Record<string, unknown> & {
|
|
prompts?: unknown[];
|
|
providers?: unknown[];
|
|
targets?: unknown[];
|
|
tests?: unknown[];
|
|
redteam?: Record<string, unknown>;
|
|
};
|
|
|
|
type ProviderContext = {
|
|
vars?: Record<string, string>;
|
|
};
|
|
|
|
type ProviderResult = {
|
|
output?: unknown;
|
|
error?: unknown;
|
|
metadata?: unknown;
|
|
};
|
|
|
|
type FixtureProvider = {
|
|
id(): string;
|
|
callApi(
|
|
prompt?: unknown,
|
|
context?: ProviderContext,
|
|
options?: Record<string, unknown>,
|
|
): Promise<ProviderResult>;
|
|
};
|
|
|
|
type FixtureProviderModule = {
|
|
default: new (options?: Record<string, unknown>) => FixtureProvider;
|
|
};
|
|
|
|
type SkillFrontmatter = {
|
|
name: string;
|
|
description: string;
|
|
};
|
|
|
|
function readText(filePath: string) {
|
|
return fs.readFileSync(filePath, 'utf8').replace(/\r\n?/g, '\n');
|
|
}
|
|
|
|
function toPosixPath(filePath: string) {
|
|
return filePath.split(path.sep).join('/');
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function expectRecord(value: unknown, context: string): asserts value is Record<string, unknown> {
|
|
expect(isRecord(value), `${context} should be an object`).toBe(true);
|
|
}
|
|
|
|
function listFiles(root: string, predicate: (filePath: string) => boolean): string[] {
|
|
const pending = [root];
|
|
const files: string[] = [];
|
|
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
const entryPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
pending.push(entryPath);
|
|
} else if (predicate(entryPath)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return files.sort();
|
|
}
|
|
|
|
function extractFileReference(reference: string): string | undefined {
|
|
if (!reference.startsWith('file://')) {
|
|
return undefined;
|
|
}
|
|
const filePathWithSuffix = reference.slice('file://'.length);
|
|
const suffixIndex = filePathWithSuffix.indexOf(':');
|
|
return suffixIndex === -1 ? filePathWithSuffix : filePathWithSuffix.slice(0, suffixIndex);
|
|
}
|
|
|
|
function extractFileFunctionName(reference: string): string | undefined {
|
|
if (!reference.startsWith('file://')) {
|
|
return undefined;
|
|
}
|
|
const filePathWithSuffix = reference.slice('file://'.length);
|
|
const suffixIndex = filePathWithSuffix.indexOf(':');
|
|
return suffixIndex === -1 ? undefined : filePathWithSuffix.slice(suffixIndex + 1);
|
|
}
|
|
|
|
function expectFileReferenceExists(reference: string, baseDir: string, context: string) {
|
|
const filePath = extractFileReference(reference);
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
|
|
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(baseDir, filePath);
|
|
const pathToCheck = /[*?[\]{}]/.test(filePath) ? path.dirname(absolutePath) : absolutePath;
|
|
expect(fs.existsSync(pathToCheck), `${context}: ${reference} resolved to ${pathToCheck}`).toBe(
|
|
true,
|
|
);
|
|
}
|
|
|
|
function expectProviderFileReferencesExist(
|
|
providers: unknown[] | undefined,
|
|
configDir: string,
|
|
context: string,
|
|
) {
|
|
for (const provider of providers ?? []) {
|
|
if (typeof provider === 'string') {
|
|
expectFileReferenceExists(provider, configDir, context);
|
|
} else if (isRecord(provider) && typeof provider.id === 'string') {
|
|
expectFileReferenceExists(provider.id, configDir, context);
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectPythonProviderReferencesFromConfig(
|
|
configPath: string,
|
|
config: PromptfooFixtureConfig,
|
|
): { absolutePath: string; context: string; functionName: string; reference: string }[] {
|
|
const configDir = path.dirname(configPath);
|
|
const references: {
|
|
absolutePath: string;
|
|
context: string;
|
|
functionName: string;
|
|
reference: string;
|
|
}[] = [];
|
|
const addReference = (reference: string, baseDir: string, context: string) => {
|
|
const filePath = extractFileReference(reference);
|
|
if (!filePath?.endsWith('.py')) {
|
|
return;
|
|
}
|
|
references.push({
|
|
absolutePath: path.isAbsolute(filePath) ? filePath : path.resolve(baseDir, filePath),
|
|
context,
|
|
functionName: extractFileFunctionName(reference) || 'call_api',
|
|
reference,
|
|
});
|
|
};
|
|
|
|
for (const provider of config.providers ?? []) {
|
|
if (typeof provider === 'string') {
|
|
addReference(provider, configDir, `${configPath} providers`);
|
|
} else if (isRecord(provider) && typeof provider.id === 'string') {
|
|
addReference(provider.id, configDir, `${configPath} providers`);
|
|
}
|
|
}
|
|
|
|
for (const target of config.targets ?? []) {
|
|
if (typeof target === 'string') {
|
|
addReference(target, configDir, `${configPath} targets`);
|
|
} else if (isRecord(target) && typeof target.id === 'string') {
|
|
addReference(target.id, configDir, `${configPath} targets`);
|
|
}
|
|
}
|
|
|
|
const redteamProvider = config.redteam?.provider;
|
|
if (typeof redteamProvider === 'string') {
|
|
addReference(redteamProvider, repoRoot, `${configPath} redteam.provider`);
|
|
}
|
|
|
|
return references;
|
|
}
|
|
|
|
function expectNoLiteralSecrets(filePath: string) {
|
|
const text = readText(filePath);
|
|
|
|
expect(text, filePath).not.toMatch(/sk-[A-Za-z0-9_-]{20,}/);
|
|
expect(text, filePath).not.toMatch(/AKIA[0-9A-Z]{16}/);
|
|
expect(text, filePath).not.toMatch(/AIza[0-9A-Za-z_-]{20,}/);
|
|
expect(text, filePath).not.toMatch(/-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----/);
|
|
expect(text, filePath).not.toMatch(/:\s*['"]?\$[A-Z][A-Z0-9_]+/);
|
|
expect(text, filePath).not.toMatch(/Bearer\s+\$[A-Z][A-Z0-9_]+/);
|
|
}
|
|
|
|
async function loadFixtureProvider(
|
|
relativePath: string,
|
|
options?: Record<string, unknown>,
|
|
): Promise<FixtureProvider> {
|
|
const modulePath = path.join(fixtureRoot, relativePath);
|
|
const module = (await import(pathToFileURL(modulePath).href)) as FixtureProviderModule;
|
|
return new module.default(options);
|
|
}
|
|
|
|
function parseOutputJson(result: ProviderResult): Record<string, unknown> {
|
|
expect(result.error).toBeUndefined();
|
|
expect(typeof result.output).toBe('string');
|
|
return JSON.parse(String(result.output)) as Record<string, unknown>;
|
|
}
|
|
|
|
function readSkillFrontmatter(skillRoot: string): SkillFrontmatter {
|
|
const skill = readText(path.join(skillRoot, 'SKILL.md'));
|
|
const match = /^---\n([\s\S]*?)\n---/.exec(skill);
|
|
expect(match, `${skillRoot} should have YAML frontmatter`).not.toBeNull();
|
|
if (!match) {
|
|
throw new Error(`Missing YAML frontmatter in ${skillRoot}`);
|
|
}
|
|
return yaml.load(match[1]) as SkillFrontmatter;
|
|
}
|
|
|
|
function listSkillMarkdownFiles(): string[] {
|
|
return listFiles(pluginRoot, (filePath) => filePath.endsWith('.md'));
|
|
}
|
|
|
|
function expectOpenApiHelperFailure(scriptPath: string, spec: unknown, expectedMessage: string) {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-helper-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(spec));
|
|
expect(() =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
scriptPath,
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'brokenOperation',
|
|
'--base-url-env',
|
|
'BROKEN_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' },
|
|
),
|
|
).toThrow(expectedMessage);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function openApiExamplePrecedenceSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
components: {
|
|
examples: {
|
|
TenantExample: {
|
|
value: 'tenant-from-example-ref',
|
|
},
|
|
},
|
|
},
|
|
paths: {
|
|
'/example/{invoice-id}': {
|
|
post: {
|
|
operationId: 'createExampleNote',
|
|
parameters: [
|
|
{
|
|
name: 'invoice-id',
|
|
in: 'path',
|
|
required: true,
|
|
example: 'invoice-from-parameter-example',
|
|
schema: { type: 'string' },
|
|
},
|
|
{
|
|
name: 'tenant-id',
|
|
in: 'query',
|
|
required: true,
|
|
examples: {
|
|
tenant: { $ref: '#/components/examples/TenantExample' },
|
|
},
|
|
schema: { type: 'string' },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
example: {
|
|
user_id: 'user-from-media-example',
|
|
note_type: 'note-type-from-media-example',
|
|
message: 'media message should not override prompt smoke',
|
|
},
|
|
schema: {
|
|
type: 'object',
|
|
required: ['user_id', 'message', 'note_type'],
|
|
properties: {
|
|
user_id: { type: 'string' },
|
|
message: { type: 'string' },
|
|
note_type: { type: 'string', enum: ['note-type-from-schema-enum'] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Example response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiVendorJsonSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/vendor-json': {
|
|
post: {
|
|
operationId: 'createVendorJsonNote',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/vnd.promptfoo.note+json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['user_id', 'message'],
|
|
properties: {
|
|
user_id: { type: 'string', example: 'vendor-json-user' },
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Vendor JSON response',
|
|
content: {
|
|
'application/vnd.promptfoo.response+json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiOAuthSecuritySpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
components: {
|
|
securitySchemes: {
|
|
OAuthToken: {
|
|
type: 'oauth2',
|
|
flows: {
|
|
clientCredentials: {
|
|
tokenUrl: 'https://auth.example.test/oauth/token',
|
|
scopes: {
|
|
'invoices:read': 'Read invoice answers',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
OpenIdToken: {
|
|
type: 'openIdConnect',
|
|
openIdConnectUrl: 'https://auth.example.test/.well-known/openid-configuration',
|
|
},
|
|
},
|
|
},
|
|
paths: {
|
|
'/oauth-search': {
|
|
get: {
|
|
operationId: 'oauthSearch',
|
|
security: [{ OAuthToken: ['invoices:read'] }],
|
|
parameters: [
|
|
{ name: 'q', in: 'query', schema: { type: 'string' } },
|
|
{ name: 'user_id', in: 'query', schema: { type: 'string', example: 'oauth-user' } },
|
|
],
|
|
responses: {
|
|
'200': {
|
|
description: 'OAuth response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/openid-search': {
|
|
get: {
|
|
operationId: 'openIdSearch',
|
|
security: [{ OpenIdToken: [] }],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'OpenID response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiConjunctiveAuthSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
components: {
|
|
securitySchemes: {
|
|
BearerToken: {
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
},
|
|
HeaderApiKey: {
|
|
type: 'apiKey',
|
|
in: 'header',
|
|
name: 'X-API-Key',
|
|
},
|
|
SessionCookie: {
|
|
type: 'apiKey',
|
|
in: 'cookie',
|
|
name: 'session_id',
|
|
},
|
|
CsrfCookie: {
|
|
type: 'apiKey',
|
|
in: 'cookie',
|
|
name: 'csrf_token',
|
|
},
|
|
UnsupportedMtls: {
|
|
type: 'mutualTLS',
|
|
},
|
|
},
|
|
},
|
|
paths: {
|
|
'/conjunctive-search': {
|
|
get: {
|
|
operationId: 'conjunctiveSearch',
|
|
security: [{ BearerToken: [], HeaderApiKey: [], SessionCookie: [], CsrfCookie: [] }],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'Conjunctive auth response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/fallback-conjunctive-search': {
|
|
get: {
|
|
operationId: 'fallbackConjunctiveSearch',
|
|
security: [
|
|
{ BearerToken: [], UnsupportedMtls: [] },
|
|
{ BearerToken: [], HeaderApiKey: [] },
|
|
],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'Fallback conjunctive auth response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/partial-conjunctive-search': {
|
|
get: {
|
|
operationId: 'partialConjunctiveSearch',
|
|
security: [{ BearerToken: [], UnsupportedMtls: [] }],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'Partial conjunctive auth response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/explicit-no-auth-search': {
|
|
get: {
|
|
operationId: 'explicitNoAuthSearch',
|
|
security: [],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'Explicit no auth response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/empty-requirement-search': {
|
|
get: {
|
|
operationId: 'emptyRequirementSearch',
|
|
security: [{}],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'Empty security requirement response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/unsupported-auth-search': {
|
|
get: {
|
|
operationId: 'unsupportedAuthSearch',
|
|
security: [{ UnsupportedMtls: [] }],
|
|
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
responses: {
|
|
'200': {
|
|
description: 'Unsupported auth response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiAllOfSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
components: {
|
|
schemas: {
|
|
IdentityPart: {
|
|
type: 'object',
|
|
required: ['user_id'],
|
|
properties: {
|
|
user_id: { type: 'string', example: 'allof-user' },
|
|
},
|
|
},
|
|
MessagePart: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
NotePart: {
|
|
type: 'object',
|
|
required: ['note_type'],
|
|
properties: {
|
|
note_type: { type: 'string', default: 'internal-note' },
|
|
},
|
|
},
|
|
AllOfNoteRequest: {
|
|
allOf: [
|
|
{ $ref: '#/components/schemas/IdentityPart' },
|
|
{ $ref: '#/components/schemas/MessagePart' },
|
|
{ $ref: '#/components/schemas/NotePart' },
|
|
],
|
|
},
|
|
TraceResponse: {
|
|
type: 'object',
|
|
properties: {
|
|
trace_id: { type: 'string' },
|
|
},
|
|
},
|
|
AnswerResponse: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
AllOfNoteResponse: {
|
|
allOf: [
|
|
{ $ref: '#/components/schemas/TraceResponse' },
|
|
{ $ref: '#/components/schemas/AnswerResponse' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
paths: {
|
|
'/allof-note': {
|
|
post: {
|
|
operationId: 'createAllOfNote',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/AllOfNoteRequest' },
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'AllOf response',
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/AllOfNoteResponse' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiRepeatedRefSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
components: {
|
|
schemas: {
|
|
SharedIdentity: {
|
|
type: 'object',
|
|
required: ['user_id'],
|
|
properties: {
|
|
user_id: { type: 'string', example: 'repeat-user' },
|
|
},
|
|
},
|
|
SharedMessage: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
RepeatedRefRequest: {
|
|
allOf: [
|
|
{ $ref: '#/components/schemas/SharedIdentity' },
|
|
{ $ref: '#/components/schemas/SharedIdentity' },
|
|
{ $ref: '#/components/schemas/SharedMessage' },
|
|
],
|
|
},
|
|
OutputPart: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
TracePart: {
|
|
type: 'object',
|
|
properties: {
|
|
trace_id: { type: 'string' },
|
|
},
|
|
},
|
|
RepeatedRefResponse: {
|
|
allOf: [
|
|
{ $ref: '#/components/schemas/OutputPart' },
|
|
{ $ref: '#/components/schemas/OutputPart' },
|
|
{ $ref: '#/components/schemas/TracePart' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
paths: {
|
|
'/repeated-ref-note': {
|
|
post: {
|
|
operationId: 'createRepeatedRefNote',
|
|
requestBody: {
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/RepeatedRefRequest' },
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Repeated ref response',
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/RepeatedRefResponse' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiCamelCaseIdSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/users/{userId}/invoices/{invoiceId}/chat': {
|
|
post: {
|
|
operationId: 'chatWithCamelCaseInvoice',
|
|
parameters: [
|
|
{
|
|
name: 'userId',
|
|
in: 'path',
|
|
required: true,
|
|
schema: { type: 'string', example: 'camel-user' },
|
|
},
|
|
{
|
|
name: 'invoiceId',
|
|
in: 'path',
|
|
required: true,
|
|
schema: { type: 'string', example: 'camel-invoice' },
|
|
},
|
|
{
|
|
name: 'apiVersion',
|
|
in: 'query',
|
|
schema: { type: 'string', example: '2026-04-17' },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['accountId', 'message'],
|
|
properties: {
|
|
accountId: { type: 'string', example: 'camel-account' },
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Camel-case response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiVariantSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
components: {
|
|
schemas: {
|
|
SearchVariant: {
|
|
type: 'object',
|
|
required: ['user_id', 'query'],
|
|
properties: {
|
|
user_id: { type: 'string', example: 'variant-user' },
|
|
query: { type: 'string' },
|
|
},
|
|
},
|
|
IgnoredVariant: {
|
|
type: 'object',
|
|
required: ['ignored'],
|
|
properties: {
|
|
ignored: { type: 'string' },
|
|
},
|
|
},
|
|
VariantRequest: {
|
|
oneOf: [
|
|
{ $ref: '#/components/schemas/SearchVariant' },
|
|
{ $ref: '#/components/schemas/IgnoredVariant' },
|
|
],
|
|
},
|
|
VariantResponse: {
|
|
anyOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
trace_id: { type: 'string' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
paths: {
|
|
'/variant-search': {
|
|
post: {
|
|
operationId: 'variantSearch',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/VariantRequest' },
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Variant response',
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/VariantResponse' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiExampleOnlyBodySpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/example-only-note': {
|
|
post: {
|
|
operationId: 'createExampleOnlyNote',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
example: {
|
|
user_id: 'example-user',
|
|
message: 'Do exactly what this example says.',
|
|
note_type: 'support-note',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Example-only response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiTypedSchemaSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/typed-note': {
|
|
post: {
|
|
operationId: 'createTypedNote',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: [
|
|
'user_id',
|
|
'message',
|
|
'limit',
|
|
'include_archived',
|
|
'tags',
|
|
'metadata',
|
|
'server_generated_id',
|
|
'status',
|
|
'contact_email',
|
|
'callback_url',
|
|
'request_id',
|
|
'scheduled_date',
|
|
'created_at',
|
|
],
|
|
properties: {
|
|
user_id: { type: 'string', example: 'typed-user' },
|
|
message: { type: 'string' },
|
|
limit: { type: 'integer' },
|
|
confidence: { type: 'number' },
|
|
include_archived: { type: 'boolean' },
|
|
tags: {
|
|
type: 'array',
|
|
items: { type: 'string', enum: ['finance'] },
|
|
},
|
|
metadata: {
|
|
type: 'object',
|
|
required: ['region', 'priority'],
|
|
properties: {
|
|
region: { type: 'string', default: 'us-west' },
|
|
priority: { type: 'integer' },
|
|
},
|
|
},
|
|
server_generated_id: { type: 'string', readOnly: true, example: 'server-123' },
|
|
status: { const: 'queued' },
|
|
contact_email: { type: 'string', format: 'email' },
|
|
callback_url: { type: 'string', format: 'uri' },
|
|
request_id: { type: 'string', format: 'uuid' },
|
|
scheduled_date: { type: 'string', format: 'date' },
|
|
created_at: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Typed response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiParameterOverrideSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/override/{resource_id}': {
|
|
parameters: [
|
|
{
|
|
name: 'api-version',
|
|
in: 'query',
|
|
schema: { type: 'string', example: 'path-version' },
|
|
},
|
|
{
|
|
name: 'X-Trace-Id',
|
|
in: 'header',
|
|
schema: { type: 'string', example: 'path-trace' },
|
|
},
|
|
],
|
|
post: {
|
|
operationId: 'overrideParameters',
|
|
parameters: [
|
|
{
|
|
name: 'resource_id',
|
|
in: 'path',
|
|
required: true,
|
|
schema: { type: 'string', example: 'resource-123' },
|
|
},
|
|
{
|
|
name: 'api-version',
|
|
in: 'query',
|
|
schema: { type: 'string', example: 'operation-version' },
|
|
},
|
|
{
|
|
name: 'X-Trace-Id',
|
|
in: 'header',
|
|
schema: { type: 'string', example: 'operation-trace' },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Override response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiWriteOnlyResponseSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/write-only-response': {
|
|
post: {
|
|
operationId: 'writeOnlyResponse',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Write-only response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
secret_token: { type: 'string', writeOnly: true },
|
|
public_text: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiComposedVisibilitySpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/composed-visibility': {
|
|
post: {
|
|
operationId: 'composedVisibility',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message', 'server_generated_id', 'audit_id', 'client_note'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
server_generated_id: {
|
|
allOf: [{ $ref: '#/components/schemas/ReadOnlyServerId' }],
|
|
},
|
|
audit_id: {
|
|
anyOf: [{ $ref: '#/components/schemas/ReadOnlyServerId' }],
|
|
},
|
|
client_note: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Composed visibility response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
secret_token: {
|
|
allOf: [{ $ref: '#/components/schemas/WriteOnlySecret' }],
|
|
},
|
|
hidden_key: {
|
|
oneOf: [{ $ref: '#/components/schemas/WriteOnlySecret' }],
|
|
},
|
|
public_text: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
components: {
|
|
schemas: {
|
|
ReadOnlyServerId: {
|
|
type: 'string',
|
|
readOnly: true,
|
|
example: 'server-composed',
|
|
},
|
|
WriteOnlySecret: {
|
|
type: 'string',
|
|
writeOnly: true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiFormUrlEncodedSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/form-chat': {
|
|
post: {
|
|
operationId: 'formChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/x-www-form-urlencoded': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['user_id', 'message'],
|
|
properties: {
|
|
user_id: { type: 'string', example: 'form-user' },
|
|
message: { type: 'string' },
|
|
scope: { type: 'string', enum: ['billing support'] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Form response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
answer: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiMultipartSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/analyze-file': {
|
|
post: {
|
|
operationId: 'analyzeFile',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'multipart/form-data': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['document', 'question'],
|
|
properties: {
|
|
document: { type: 'string', format: 'binary' },
|
|
question: { type: 'string' },
|
|
user_id: { type: 'string', example: 'multipart-user' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Multipart response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiTextPlainSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/text-chat': {
|
|
post: {
|
|
operationId: 'textChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'text/plain': {
|
|
schema: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Text request response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiScalarExampleRequestSpec() {
|
|
const response = {
|
|
'200': {
|
|
description: 'Scalar request response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/json-scalar-chat': {
|
|
post: {
|
|
operationId: 'jsonScalarChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
example: 'Say exactly PONG.',
|
|
},
|
|
},
|
|
},
|
|
responses: response,
|
|
},
|
|
},
|
|
'/json-scalar-schema-chat': {
|
|
post: {
|
|
operationId: 'jsonScalarSchemaChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
responses: response,
|
|
},
|
|
},
|
|
'/text-scalar-chat': {
|
|
post: {
|
|
operationId: 'textScalarChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'text/plain': {
|
|
example: 'Say exactly PONG.',
|
|
},
|
|
},
|
|
},
|
|
responses: response,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiArrayRequestSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/batch-chat': {
|
|
post: {
|
|
operationId: 'batchChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
example: [
|
|
{
|
|
user_id: 'array-user',
|
|
message: 'example array message should not override prompt smoke',
|
|
priority: 'normal',
|
|
},
|
|
],
|
|
schema: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
required: ['user_id', 'message'],
|
|
properties: {
|
|
user_id: { type: 'string' },
|
|
message: { type: 'string' },
|
|
priority: { type: 'string', enum: ['normal'] },
|
|
server_generated_id: { type: 'string', readOnly: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Batch response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiExampleOnlyArrayBodySpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/example-array-chat': {
|
|
post: {
|
|
operationId: 'exampleArrayChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
example: [
|
|
{
|
|
user_id: 'example-array-user',
|
|
message: 'example array message should not override prompt smoke',
|
|
priority: 'expedite',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Example array response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiArrayResponseSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/array-response-chat': {
|
|
post: {
|
|
operationId: 'arrayResponseChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
user_id: { type: 'string', example: 'array-response-user' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Array response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
trace_id: { type: 'string' },
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiExampleOnlyArrayResponseSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/example-array-response-chat': {
|
|
post: {
|
|
operationId: 'exampleArrayResponseChat',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
user_id: { type: 'string', example: 'example-array-response-user' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Example-only array response',
|
|
content: {
|
|
'application/json': {
|
|
example: [
|
|
{
|
|
trace_id: 'trace-example',
|
|
output: 'PONG example',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function openApiUnsafeResponseFieldSpec() {
|
|
return {
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/unsafe-response-field': {
|
|
post: {
|
|
operationId: 'unsafeResponseField',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Response with unsafe object key',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
'api-version': { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'/unsafe-array-response-field': {
|
|
post: {
|
|
operationId: 'unsafeArrayResponseField',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Array response with unsafe object key',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
'200': { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('promptfoo plugin package (Codex + Claude Code)', () => {
|
|
it('declares a Codex plugin manifest with a skills directory', () => {
|
|
const manifestPath = path.join(pluginRoot, '.codex-plugin', 'plugin.json');
|
|
const manifest = JSON.parse(readText(manifestPath));
|
|
|
|
expect(manifest.name).toBe('promptfoo');
|
|
expect(manifest.skills).toBe('./skills/');
|
|
expect(manifest.interface.displayName).toBe('Promptfoo');
|
|
expect(manifest.interface.defaultPrompt).toHaveLength(3);
|
|
expect(manifest.interface.defaultPrompt.every((prompt: string) => prompt.length <= 128)).toBe(
|
|
true,
|
|
);
|
|
expect(manifest.interface.defaultPrompt).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining('eval'),
|
|
expect.stringContaining('HTTP endpoint'),
|
|
expect.stringContaining('red teaming'),
|
|
]),
|
|
);
|
|
expect(manifest.interface.composerIcon).toBe('./assets/promptfoo-panda.svg');
|
|
expect(manifest.interface.logo).toBe('./assets/promptfoo-panda.svg');
|
|
expect(manifest.interface.screenshots).toEqual([]);
|
|
expect(fs.existsSync(path.join(pluginRoot, 'skills'))).toBe(true);
|
|
expect(fs.existsSync(evalsSkillRoot)).toBe(true);
|
|
expect(fs.existsSync(providerSkillRoot)).toBe(true);
|
|
expect(fs.existsSync(redteamSetupSkillRoot)).toBe(true);
|
|
expect(fs.existsSync(redteamRunSkillRoot)).toBe(true);
|
|
expect(fs.existsSync(path.join(pluginRoot, manifest.interface.composerIcon))).toBe(true);
|
|
expect(fs.existsSync(path.join(pluginRoot, manifest.interface.logo))).toBe(true);
|
|
});
|
|
|
|
it('keeps the Codex plugin manifest within documented packaging constraints', () => {
|
|
const manifest = JSON.parse(readText(path.join(pluginRoot, '.codex-plugin', 'plugin.json')));
|
|
const skillsPath = path.resolve(pluginRoot, manifest.skills);
|
|
|
|
expect(Object.keys(manifest).sort()).toEqual(
|
|
[
|
|
'author',
|
|
'description',
|
|
'homepage',
|
|
'interface',
|
|
'keywords',
|
|
'license',
|
|
'name',
|
|
'repository',
|
|
'skills',
|
|
'version',
|
|
].sort(),
|
|
);
|
|
expect(Object.keys(manifest.interface).sort()).toEqual(
|
|
[
|
|
'brandColor',
|
|
'capabilities',
|
|
'category',
|
|
'composerIcon',
|
|
'defaultPrompt',
|
|
'developerName',
|
|
'displayName',
|
|
'logo',
|
|
'longDescription',
|
|
'privacyPolicyURL',
|
|
'screenshots',
|
|
'shortDescription',
|
|
'termsOfServiceURL',
|
|
'websiteURL',
|
|
].sort(),
|
|
);
|
|
expect(JSON.stringify(manifest)).not.toContain('[TODO:');
|
|
expect(manifest.name).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
expect(manifest.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
expect(Object.keys(manifest.author).sort()).toEqual(['email', 'name', 'url']);
|
|
expect(manifest.author.name).toBe('Promptfoo');
|
|
expect(manifest.author.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
|
|
expect(manifest.author.email).toBe('support@promptfoo.dev');
|
|
expect(manifest.author.url).toMatch(/^https:\/\//);
|
|
expect(manifest.homepage).toMatch(/^https:\/\//);
|
|
expect(manifest.repository).toMatch(/^https:\/\//);
|
|
expect(manifest.license).toBe('MIT');
|
|
expect(manifest.skills).toMatch(/^\.\//);
|
|
expect(manifest.hooks).toBeUndefined();
|
|
expect(manifest.mcpServers).toBeUndefined();
|
|
expect(manifest.apps).toBeUndefined();
|
|
expect(fs.statSync(skillsPath).isDirectory()).toBe(true);
|
|
expect(
|
|
fs
|
|
.readdirSync(skillsPath, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort(),
|
|
).toEqual(expectedSkillDirs);
|
|
expect(manifest.interface.shortDescription.length).toBeLessThanOrEqual(64);
|
|
expect(manifest.interface.longDescription.length).toBeLessThanOrEqual(160);
|
|
expect(manifest.interface.capabilities).toEqual(expect.arrayContaining(['Read', 'Write']));
|
|
expect(manifest.interface.brandColor).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
|
expect(manifest.interface.composerIcon).toMatch(/^\.\//);
|
|
expect(manifest.interface.logo).toMatch(/^\.\//);
|
|
expect(manifest.interface.screenshots).toEqual([]);
|
|
expect(new Set(manifest.interface.defaultPrompt).size).toBe(
|
|
manifest.interface.defaultPrompt.length,
|
|
);
|
|
});
|
|
|
|
it('exposes the plugin through the repo Codex marketplace', () => {
|
|
const marketplace = JSON.parse(
|
|
readText(path.join(repoRoot, '.agents', 'plugins', 'marketplace.json')),
|
|
);
|
|
const entry = (marketplace.plugins as MarketplacePlugin[]).find(
|
|
(plugin) => plugin.name === 'promptfoo',
|
|
);
|
|
|
|
expect(marketplace.name).toBe('promptfoo');
|
|
expect(marketplace.interface.displayName).toBe('Promptfoo');
|
|
expect(JSON.stringify(marketplace)).not.toContain('[TODO:');
|
|
expect(marketplace.plugins).toHaveLength(1);
|
|
expect(entry).toBeDefined();
|
|
if (!entry) {
|
|
throw new Error('Missing promptfoo marketplace entry');
|
|
}
|
|
expect(Object.keys(entry).sort()).toEqual(['category', 'name', 'policy', 'source']);
|
|
expect(Object.keys(entry.source).sort()).toEqual(['path', 'source']);
|
|
expect(Object.keys(entry.policy).sort()).toEqual(['authentication', 'installation']);
|
|
expect(entry).toEqual(
|
|
expect.objectContaining({
|
|
name: 'promptfoo',
|
|
source: { source: 'local', path: './plugins/promptfoo' },
|
|
policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
|
|
category: 'Developer Tools',
|
|
}),
|
|
);
|
|
expect(path.normalize(entry.source.path)).toBe(path.join('plugins', 'promptfoo'));
|
|
expect(
|
|
fs.existsSync(path.join(repoRoot, entry.source.path, '.codex-plugin', 'plugin.json')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('keeps the published surface to four focused skills without a meta selector', () => {
|
|
const skillDirs = fs
|
|
.readdirSync(path.join(pluginRoot, 'skills'), { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
|
|
expect(skillDirs).toEqual(expectedSkillDirs);
|
|
expect(skillDirs).not.toContain('promptfoo-skill-selector');
|
|
expect(skillDirs).not.toContain('promptfoo-meta');
|
|
});
|
|
|
|
it('keeps the shared plugin bundle file inventory intentionally small', () => {
|
|
const packagedFiles = listFiles(pluginRoot, () => true)
|
|
.map((filePath) => toPosixPath(path.relative(pluginRoot, filePath)))
|
|
.sort();
|
|
|
|
expect(packagedFiles).toEqual([
|
|
'.claude-plugin/plugin.json',
|
|
'.codex-plugin/plugin.json',
|
|
'assets/promptfoo-panda.svg',
|
|
'skills/promptfoo-evals/SKILL.md',
|
|
'skills/promptfoo-evals/agents/openai.yaml',
|
|
'skills/promptfoo-evals/references/eval-patterns.md',
|
|
'skills/promptfoo-provider-setup/SKILL.md',
|
|
'skills/promptfoo-provider-setup/agents/openai.yaml',
|
|
'skills/promptfoo-provider-setup/references/provider-patterns.md',
|
|
'skills/promptfoo-provider-setup/scripts/openapi-operation-to-config.mjs',
|
|
'skills/promptfoo-redteam-run/SKILL.md',
|
|
'skills/promptfoo-redteam-run/agents/openai.yaml',
|
|
'skills/promptfoo-redteam-run/references/redteam-run-patterns.md',
|
|
'skills/promptfoo-redteam-setup/SKILL.md',
|
|
'skills/promptfoo-redteam-setup/agents/openai.yaml',
|
|
'skills/promptfoo-redteam-setup/references/redteam-setup-patterns.md',
|
|
'skills/promptfoo-redteam-setup/scripts/openapi-operation-to-redteam-config.mjs',
|
|
]);
|
|
});
|
|
|
|
it('keeps the fixture matrix intentional and mapped to the four skills', () => {
|
|
const fixtureDirs = fs
|
|
.readdirSync(fixtureRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
|
|
expect(fixtureDirs).toEqual(expectedFixtureDirs);
|
|
expect(fixtureDirs.filter((dir) => dir.startsWith('evals-'))).toHaveLength(3);
|
|
expect(fixtureDirs.filter((dir) => dir.startsWith('provider-setup-'))).toHaveLength(8);
|
|
expect(fixtureDirs.filter((dir) => dir.startsWith('redteam-setup-'))).toHaveLength(5);
|
|
expect(fixtureDirs.filter((dir) => dir.startsWith('redteam-run-'))).toHaveLength(4);
|
|
expect(fixtureDirs).toEqual(
|
|
expect.arrayContaining([
|
|
'provider-setup-http',
|
|
'provider-setup-local-js',
|
|
'provider-setup-local-python',
|
|
'provider-setup-openapi',
|
|
'provider-setup-redteam-target',
|
|
'redteam-run-local-pass',
|
|
'redteam-run-local-python-pass',
|
|
'redteam-run-local-mixed',
|
|
'redteam-run-local-error',
|
|
'redteam-setup-live-http',
|
|
'redteam-setup-static-code',
|
|
'redteam-setup-static-code-python',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('keeps frontmatter routing descriptions precise without a selector skill', () => {
|
|
const routingExpectations: Record<
|
|
string,
|
|
{
|
|
positives: string[];
|
|
negatives: string[];
|
|
}
|
|
> = {
|
|
'promptfoo-evals': {
|
|
positives: ['non-redteam promptfoo eval suites', 'test cases', 'assertions'],
|
|
negatives: [
|
|
'Do not use',
|
|
'connecting a new target/provider',
|
|
'smoke-testing an endpoint',
|
|
'redteam plugin/strategy setup',
|
|
],
|
|
},
|
|
'promptfoo-provider-setup': {
|
|
positives: ['providers or redteam targets', 'live HTTP', 'static-code-derived'],
|
|
negatives: ['Do not', 'choosing eval assertions', 'red team plugins'],
|
|
},
|
|
'promptfoo-redteam-run': {
|
|
positives: ['Run, rerun, inspect', 'generated redteam YAML', 'attack success rate'],
|
|
negatives: ['Do not use', 'initial provider wiring', 'choosing plugins'],
|
|
},
|
|
'promptfoo-redteam-setup': {
|
|
positives: [
|
|
'purpose',
|
|
'targets',
|
|
'plugins',
|
|
'strategies',
|
|
'static-code-derived',
|
|
'generating adversarial',
|
|
],
|
|
negatives: ['Do not use', 'basic provider wiring', 'running/evaluating'],
|
|
},
|
|
};
|
|
|
|
for (const skillDir of expectedSkillDirs) {
|
|
const frontmatter = readSkillFrontmatter(path.join(pluginRoot, 'skills', skillDir));
|
|
const expectations = routingExpectations[skillDir];
|
|
|
|
expect(frontmatter.name).toBe(skillDir);
|
|
expect(frontmatter.description.length).toBeGreaterThan(140);
|
|
expect(frontmatter.description.length).toBeLessThan(520);
|
|
for (const phrase of expectations.positives) {
|
|
expect(frontmatter.description).toContain(phrase);
|
|
}
|
|
for (const phrase of expectations.negatives) {
|
|
expect(frontmatter.description).toContain(phrase);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps every skill structurally complete and progressively disclosed', () => {
|
|
for (const skillDir of expectedSkillDirs) {
|
|
const skillRoot = path.join(pluginRoot, 'skills', skillDir);
|
|
const skill = readText(path.join(skillRoot, 'SKILL.md'));
|
|
const openaiYaml = readText(path.join(skillRoot, 'agents', 'openai.yaml'));
|
|
const referencesDir = path.join(skillRoot, 'references');
|
|
const referenceFiles = fs
|
|
.readdirSync(referencesDir)
|
|
.filter((fileName) => fileName.endsWith('.md'));
|
|
|
|
expect(skill).toMatch(new RegExp(`^---\\nname: ${skillDir}\\n`));
|
|
expect(openaiYaml).toContain(`$${skillDir}`);
|
|
expect(openaiYaml).toContain('allow_implicit_invocation: true');
|
|
expect(referenceFiles.length).toBeGreaterThanOrEqual(1);
|
|
for (const referenceFile of referenceFiles) {
|
|
expect(skill).toContain(`references/${referenceFile}`);
|
|
}
|
|
expect(fs.existsSync(path.join(skillRoot, 'README.md'))).toBe(false);
|
|
expect(fs.existsSync(path.join(skillRoot, 'CHANGELOG.md'))).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('documents the shared plugin bundle on both marketplaces', () => {
|
|
const docs = readText(path.join(repoRoot, 'site', 'docs', 'integrations', 'agent-skill.md'));
|
|
|
|
expect(docs).toContain('Via Claude Code marketplace');
|
|
expect(docs).toContain('Via Codex plugin bundle');
|
|
expect(docs).toContain('/plugin install promptfoo@promptfoo');
|
|
expect(docs).toContain('intentionally no meta selector skill');
|
|
expect(docs).toContain("routes from each skill's");
|
|
expect(docs).toContain('Python providers are first-class');
|
|
expect(docs).toContain('file://provider.py:function_name');
|
|
expect(docs).toContain('local graders');
|
|
expect(docs).toContain('PROMPTFOO_PYTHON');
|
|
expect(docs).toContain('copy `plugins/promptfoo`');
|
|
expect(docs).toContain('plugins/promptfoo');
|
|
expect(docs).toContain('.agents/plugins/marketplace.json');
|
|
expect(docs).toContain('.claude-plugin/marketplace.json');
|
|
for (const skillDir of expectedSkillDirs) {
|
|
expect(docs).toContain(skillDir);
|
|
}
|
|
});
|
|
|
|
it('serves one bundle to both the Codex and Claude marketplaces', () => {
|
|
const codexManifest = JSON.parse(
|
|
readText(path.join(pluginRoot, '.codex-plugin', 'plugin.json')),
|
|
);
|
|
const claudeManifest = JSON.parse(
|
|
readText(path.join(pluginRoot, '.claude-plugin', 'plugin.json')),
|
|
);
|
|
|
|
// One plugin identity across both marketplaces: same name, version, and author.
|
|
expect(codexManifest.name).toBe('promptfoo');
|
|
expect(claudeManifest.name).toBe('promptfoo');
|
|
expect(codexManifest.version).toBe(expectedPluginVersion);
|
|
expect(claudeManifest.version).toBe(expectedPluginVersion);
|
|
expect(claudeManifest.author.name).toBe('Promptfoo');
|
|
expect(JSON.stringify(claudeManifest)).not.toContain('[TODO:');
|
|
|
|
// The standalone single-skill Claude bundle has been retired in favor of this shared bundle.
|
|
expect(fs.existsSync(path.join(repoRoot, 'plugins', 'promptfoo-evals'))).toBe(false);
|
|
|
|
// Claude marketplace entry — asserted with the same rigor as the Codex marketplace above.
|
|
const claudeMarketplace = JSON.parse(
|
|
readText(path.join(repoRoot, '.claude-plugin', 'marketplace.json')),
|
|
);
|
|
expect(claudeMarketplace.name).toBe('promptfoo');
|
|
expect(claudeMarketplace.owner.name).toBe('promptfoo');
|
|
expect(typeof claudeMarketplace.metadata.description).toBe('string');
|
|
expect(claudeMarketplace.plugins).toHaveLength(1);
|
|
expect(JSON.stringify(claudeMarketplace)).not.toContain('[TODO:');
|
|
const claudeEntry = claudeMarketplace.plugins[0];
|
|
expect(claudeEntry.name).toBe('promptfoo');
|
|
expect(claudeEntry.license).toBe('MIT');
|
|
|
|
// Parity guarantee: both marketplaces must resolve to the SAME on-disk bundle.
|
|
const codexMarketplace = JSON.parse(
|
|
readText(path.join(repoRoot, '.agents', 'plugins', 'marketplace.json')),
|
|
);
|
|
const codexEntry = (codexMarketplace.plugins as MarketplacePlugin[]).find(
|
|
(plugin) => plugin.name === 'promptfoo',
|
|
);
|
|
if (!codexEntry) {
|
|
throw new Error('Missing promptfoo Codex marketplace entry');
|
|
}
|
|
const claudeBundle = path.resolve(repoRoot, claudeEntry.source);
|
|
expect(claudeBundle).toBe(pluginRoot);
|
|
expect(path.resolve(repoRoot, codexEntry.source.path)).toBe(pluginRoot);
|
|
|
|
// Claude Code auto-discovers the same four skills as Codex from the shared bundle's skills/.
|
|
const claudeSkillDirs = fs
|
|
.readdirSync(path.join(claudeBundle, 'skills'), { withFileTypes: true })
|
|
.filter((dirent) => dirent.isDirectory())
|
|
.map((dirent) => dirent.name)
|
|
.sort();
|
|
expect(claudeSkillDirs).toEqual(expectedSkillDirs);
|
|
});
|
|
|
|
it('keeps the eval skill guidance complete and provider-aware', () => {
|
|
const evalsSkill = readText(path.join(evalsSkillRoot, 'SKILL.md'));
|
|
const evalsReference = readText(path.join(evalsSkillRoot, 'references', 'eval-patterns.md'));
|
|
|
|
for (const phrase of [
|
|
'deterministic',
|
|
'model-graded',
|
|
'tests: file://tests/*.yaml',
|
|
"apiKey: '{{env.OPENAI_API_KEY}}'",
|
|
'options.transform',
|
|
'openai:chat:gpt-4.1-mini',
|
|
'anthropic:messages:claude-sonnet-4-6',
|
|
'echo',
|
|
]) {
|
|
expect(`${evalsSkill}\n${evalsReference}`).toContain(phrase);
|
|
}
|
|
expect(evalsSkill).toContain('If the provider does not work yet, switch to');
|
|
expect(evalsReference).toContain('promptfoo-provider-setup');
|
|
});
|
|
|
|
it('keeps every repo-local Claude skill discoverable through canonical SKILL.md casing', () => {
|
|
const repoLocalSkillDirs = fs
|
|
.readdirSync(repoClaudeSkillsRoot, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
|
|
expect(repoLocalSkillDirs).toEqual(
|
|
expect.arrayContaining(['promptfoo-evals', 'redteam-plugin-development', 'search-params']),
|
|
);
|
|
|
|
for (const skillDir of repoLocalSkillDirs) {
|
|
const skillRoot = path.join(repoClaudeSkillsRoot, skillDir);
|
|
const entries = fs.readdirSync(skillRoot);
|
|
expect(entries).toContain('SKILL.md');
|
|
expect(entries).not.toContain('skill.md');
|
|
}
|
|
});
|
|
|
|
it('keeps the promptfoo-evals contributor copy self-contained', () => {
|
|
// The repo-local copy is standalone: the sibling skills it could route to
|
|
// (promptfoo-provider-setup, the redteam skills) are not present under
|
|
// .claude/skills/, so it must not hand off to them and dangle.
|
|
const contributorEvals = readText(
|
|
path.join(repoClaudeSkillsRoot, 'promptfoo-evals', 'SKILL.md'),
|
|
);
|
|
for (const sibling of [
|
|
'promptfoo-provider-setup',
|
|
'promptfoo-redteam-setup',
|
|
'promptfoo-redteam-run',
|
|
]) {
|
|
expect(contributorEvals).not.toContain(sibling);
|
|
}
|
|
});
|
|
|
|
it('keeps shared repo-local contributor skills aligned between Claude and Codex', () => {
|
|
for (const skillDir of ['redteam-plugin-development', 'search-params']) {
|
|
const claudeSkill = readText(path.join(repoClaudeSkillsRoot, skillDir, 'SKILL.md'));
|
|
const codexSkill = readText(path.join(repoCodexSkillsRoot, skillDir, 'SKILL.md'));
|
|
|
|
expect(codexSkill).toBe(claudeSkill);
|
|
}
|
|
});
|
|
|
|
it('keeps every agents/openai.yaml aligned with UI metadata constraints', () => {
|
|
const expectedDefaultPromptPhrases: Record<string, string[]> = {
|
|
'promptfoo-evals': ['focused eval', 'local JS/Python providers'],
|
|
'promptfoo-provider-setup': [
|
|
'HTTP endpoint',
|
|
'OpenAPI operation',
|
|
'Python provider',
|
|
'app code',
|
|
],
|
|
'promptfoo-redteam-run': ['execute', 'HTTP, Python, or JS target'],
|
|
'promptfoo-redteam-setup': ['live endpoint', 'OpenAPI spec', 'code'],
|
|
};
|
|
|
|
for (const skillDir of expectedSkillDirs) {
|
|
const skillRoot = path.join(pluginRoot, 'skills', skillDir);
|
|
const metadata = yaml.load(readText(path.join(skillRoot, 'agents', 'openai.yaml'))) as
|
|
| OpenAiYaml
|
|
| undefined;
|
|
|
|
expect(metadata).toBeDefined();
|
|
if (!metadata) {
|
|
throw new Error(`Missing OpenAI metadata for ${skillDir}`);
|
|
}
|
|
expect(Object.keys(metadata).sort()).toEqual(['interface', 'policy']);
|
|
expect(Object.keys(metadata.interface).sort()).toEqual([
|
|
'default_prompt',
|
|
'display_name',
|
|
'short_description',
|
|
]);
|
|
expect(Object.keys(metadata.policy).sort()).toEqual(['allow_implicit_invocation']);
|
|
expect(JSON.stringify(metadata)).not.toContain('[TODO:');
|
|
expect(metadata.interface.display_name).toMatch(/^Promptfoo /);
|
|
expect(metadata.interface.short_description.length).toBeGreaterThanOrEqual(25);
|
|
expect(metadata.interface.short_description.length).toBeLessThanOrEqual(64);
|
|
expect(metadata.interface.default_prompt).toContain(`$${skillDir}`);
|
|
expect(metadata.interface.default_prompt).not.toContain('\n');
|
|
expect(metadata.interface.default_prompt.trim()).toBe(metadata.interface.default_prompt);
|
|
expect(metadata.interface.default_prompt.endsWith('.')).toBe(true);
|
|
expect(metadata.interface.default_prompt.length).toBeLessThanOrEqual(128);
|
|
for (const phrase of expectedDefaultPromptPhrases[skillDir]) {
|
|
expect(metadata.interface.default_prompt).toContain(phrase);
|
|
}
|
|
expect(metadata.policy.allow_implicit_invocation).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('keeps skill bodies concise and detailed examples in references', () => {
|
|
for (const skillDir of expectedSkillDirs) {
|
|
const skillRoot = path.join(pluginRoot, 'skills', skillDir);
|
|
const skillLineCount = readText(path.join(skillRoot, 'SKILL.md'))
|
|
.trimEnd()
|
|
.split('\n').length;
|
|
const referenceFiles = fs
|
|
.readdirSync(path.join(skillRoot, 'references'))
|
|
.filter((fileName) => fileName.endsWith('.md'));
|
|
|
|
expect(skillLineCount).toBeLessThan(200);
|
|
for (const referenceFile of referenceFiles) {
|
|
const referenceLineCount = readText(path.join(skillRoot, 'references', referenceFile))
|
|
.trimEnd()
|
|
.split('\n').length;
|
|
expect(referenceLineCount).toBeLessThan(260);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps runnable CLI examples aligned with repo command conventions', () => {
|
|
const markdownFiles = listSkillMarkdownFiles();
|
|
|
|
expect(markdownFiles.length).toBeGreaterThanOrEqual(8);
|
|
for (const filePath of markdownFiles) {
|
|
const lines = readText(filePath).split('\n');
|
|
for (const [index, line] of lines.entries()) {
|
|
const context = `${filePath}:${index + 1}`;
|
|
const commandLine = /^\s*(?:[A-Z0-9_]+=.+?\s+)?/.source;
|
|
if (line.includes('npm run local')) {
|
|
expect(line, context).toContain('npm run local --');
|
|
expect(line, context).not.toContain('npm run local -- view');
|
|
}
|
|
if (new RegExp(`${commandLine}npx promptfoo@latest validate `).test(line)) {
|
|
expect(line, context).toMatch(/validate (?:config|target) -c /);
|
|
}
|
|
if (new RegExp(`${commandLine}npm run local -- validate `).test(line)) {
|
|
expect(line, context).toMatch(/validate (?:config|target) -c /);
|
|
}
|
|
if (
|
|
new RegExp(
|
|
`${commandLine}(?:npm run local -- eval|npx promptfoo@latest eval|promptfoo eval)\\b`,
|
|
).test(line)
|
|
) {
|
|
expect(line, context).toContain('--no-cache');
|
|
expect(line, context).toContain('--no-share');
|
|
}
|
|
if (new RegExp(`${commandLine}npm run local -- redteam eval `).test(line)) {
|
|
expect(line, context).toContain('--no-cache');
|
|
expect(line, context).toContain('--no-progress-bar');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps fixture YAML parseable with resolvable local file references', () => {
|
|
const configPaths = listFiles(fixtureRoot, (filePath) =>
|
|
['promptfooconfig.yaml', 'redteam.yaml'].includes(path.basename(filePath)),
|
|
);
|
|
|
|
expect(configPaths).toHaveLength(expectedFixtureDirs.length);
|
|
for (const configPath of configPaths) {
|
|
const config = yaml.load(readText(configPath));
|
|
expect(isRecord(config), `${configPath} should parse to an object`).toBe(true);
|
|
if (!isRecord(config)) {
|
|
throw new Error(`Fixture config did not parse to an object: ${configPath}`);
|
|
}
|
|
|
|
const typedConfig = config as PromptfooFixtureConfig;
|
|
const configDir = path.dirname(configPath);
|
|
expect(typeof typedConfig.description).toBe('string');
|
|
|
|
for (const prompt of typedConfig.prompts ?? []) {
|
|
if (typeof prompt === 'string') {
|
|
expectFileReferenceExists(prompt, configDir, `${configPath} prompts`);
|
|
}
|
|
}
|
|
for (const testRef of typedConfig.tests ?? []) {
|
|
if (typeof testRef === 'string') {
|
|
expectFileReferenceExists(testRef, configDir, `${configPath} tests`);
|
|
}
|
|
}
|
|
expectProviderFileReferencesExist(
|
|
typedConfig.providers,
|
|
configDir,
|
|
`${configPath} providers`,
|
|
);
|
|
expectProviderFileReferencesExist(typedConfig.targets, configDir, `${configPath} targets`);
|
|
|
|
const redteamProvider = typedConfig.redteam?.provider;
|
|
if (typeof redteamProvider === 'string') {
|
|
expectFileReferenceExists(redteamProvider, repoRoot, `${configPath} redteam.provider`);
|
|
const providerPath = extractFileReference(redteamProvider);
|
|
if (providerPath && !path.isAbsolute(providerPath)) {
|
|
expect(providerPath.startsWith('.')).toBe(false);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps Python file providers executable and on the promptfoo function contract', async () => {
|
|
const configPaths = listFiles(fixtureRoot, (filePath) =>
|
|
['promptfooconfig.yaml', 'redteam.yaml'].includes(path.basename(filePath)),
|
|
);
|
|
const pythonReferences = new Map<
|
|
string,
|
|
{ absolutePath: string; context: string; functionName: string; reference: string }
|
|
>();
|
|
|
|
for (const configPath of configPaths) {
|
|
const config = yaml.load(readText(configPath));
|
|
expectRecord(config, `${configPath} fixture config`);
|
|
for (const reference of collectPythonProviderReferencesFromConfig(
|
|
configPath,
|
|
config as PromptfooFixtureConfig,
|
|
)) {
|
|
pythonReferences.set(`${reference.absolutePath}:${reference.functionName}`, reference);
|
|
}
|
|
}
|
|
|
|
expect(
|
|
[...pythonReferences.values()]
|
|
.map(
|
|
({ absolutePath, functionName }) =>
|
|
`${toPosixPath(path.relative(fixtureRoot, absolutePath))}:${functionName}`,
|
|
)
|
|
.sort(),
|
|
).toEqual([
|
|
'evals-local-python/provider.py:call_api',
|
|
'provider-setup-local-python/provider.py:invoice_agent_provider',
|
|
'redteam-run-local-python-pass/grader.py:grade_redteam',
|
|
'redteam-run-local-python-pass/target.py:call_api',
|
|
'redteam-setup-static-code-python/redteam-generator.py:generate_redteam_invoice_prompt',
|
|
'redteam-setup-static-code-python/target.py:invoice_redteam_target',
|
|
]);
|
|
|
|
const pythonEnv = { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' };
|
|
await Promise.all(
|
|
[...pythonReferences.values()].map(
|
|
async ({ absolutePath, context, functionName, reference }) => {
|
|
const script = readText(absolutePath);
|
|
expect(script, `${context}: ${reference}`).toContain(`def ${functionName}(`);
|
|
|
|
const { stdout } = await execFileAsync(fixturePythonExecutable, [absolutePath], {
|
|
cwd: path.dirname(absolutePath),
|
|
encoding: 'utf8',
|
|
env: pythonEnv,
|
|
});
|
|
const parsed = JSON.parse(stdout) as ProviderResult;
|
|
expect(typeof parsed.output, `${absolutePath} should print JSON with output`).toBe(
|
|
'string',
|
|
);
|
|
expect(
|
|
String(parsed.output).length,
|
|
`${absolutePath} output should be non-empty`,
|
|
).toBeGreaterThan(0);
|
|
},
|
|
),
|
|
);
|
|
});
|
|
|
|
it('keeps packaged configs and fixtures free of literal secrets', () => {
|
|
const checkedFiles = [
|
|
path.join(repoRoot, '.agents', 'plugins', 'marketplace.json'),
|
|
path.join(pluginRoot, '.codex-plugin', 'plugin.json'),
|
|
...listFiles(pluginRoot, (filePath) => filePath.endsWith(path.join('agents', 'openai.yaml'))),
|
|
...listFiles(fixtureRoot, (filePath) =>
|
|
['.yaml', '.mjs', '.py'].includes(path.extname(filePath)),
|
|
),
|
|
];
|
|
|
|
expect(checkedFiles.length).toBeGreaterThanOrEqual(25);
|
|
for (const filePath of checkedFiles) {
|
|
const text = readText(filePath);
|
|
expectNoLiteralSecrets(filePath);
|
|
if (filePath.endsWith('.yaml')) {
|
|
expect(text, filePath).not.toMatch(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)/);
|
|
const envLines = text.split('\n').filter((line) => line.includes('{{env.'));
|
|
for (const line of envLines) {
|
|
expect(line, `${filePath}: ${line}`).toMatch(
|
|
/['"][^'"]*\{\{env\.[A-Z0-9_]+\}\}[^'"]*['"]/,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('imports and exercises bundled local provider modules', async () => {
|
|
const evalProvider = await loadFixtureProvider('evals-local-js/provider.mjs');
|
|
const evalResult = await evalProvider.callApi('Reply about billing.', {
|
|
vars: { topic: 'billing', trace_id: 'trace-123' },
|
|
});
|
|
expect(evalProvider.id()).toBe('evals-local-js-provider');
|
|
expect(evalResult.output).toContain('PONG topic=billing');
|
|
expect(evalResult.output).toContain('trace id trace-123');
|
|
|
|
const configuredEvalProvider = await loadFixtureProvider('evals-local-js/provider.mjs', {
|
|
config: { defaultTopic: 'config-topic', defaultTraceId: 'config-trace' },
|
|
});
|
|
const configuredEvalResult = await configuredEvalProvider.callApi('Reply with defaults.', {
|
|
vars: {},
|
|
});
|
|
expect(configuredEvalResult.output).toContain('PONG topic=config-topic');
|
|
expect(configuredEvalResult.output).toContain('trace id config-trace');
|
|
|
|
const evalPythonOutput = execFileSync(fixturePythonExecutable, ['provider.py'], {
|
|
cwd: path.join(fixtureRoot, 'evals-local-python'),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' },
|
|
});
|
|
const evalPythonResult = JSON.parse(evalPythonOutput) as ProviderResult;
|
|
expect(evalPythonResult.output).toContain('PONG python topic=smoke');
|
|
expect(evalPythonResult.output).toContain('trace id eval-py-123');
|
|
expect(evalPythonResult.metadata).toEqual({ topic: 'smoke', trace_id: 'eval-py-123' });
|
|
|
|
const evalPythonConfigOutput = execFileSync(
|
|
fixturePythonExecutable,
|
|
[
|
|
'-c',
|
|
[
|
|
'import json, runpy',
|
|
'ns = runpy.run_path("provider.py")',
|
|
'print(json.dumps(ns["call_api"]("Reply with defaults.", {"config": {"defaultTopic": "py-config-topic", "defaultTraceId": "py-config-trace"}}, {"vars": {}}), sort_keys=True))',
|
|
].join('; '),
|
|
],
|
|
{
|
|
cwd: path.join(fixtureRoot, 'evals-local-python'),
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
OAIPKG_DISABLE_META_MISSING: '1',
|
|
PYTHONSAFEPATH: '1',
|
|
},
|
|
},
|
|
);
|
|
const evalPythonConfigResult = JSON.parse(evalPythonConfigOutput) as ProviderResult;
|
|
expect(evalPythonConfigResult.output).toContain('PONG python topic=py-config-topic');
|
|
expect(evalPythonConfigResult.output).toContain('trace id py-config-trace');
|
|
|
|
const jsonProvider = await loadFixtureProvider('evals-json-rubric/provider.mjs');
|
|
const jsonResult = parseOutputJson(
|
|
await jsonProvider.callApi('Return invoice decision JSON.', {
|
|
vars: { invoice_id: 'inv-123' },
|
|
}),
|
|
);
|
|
expect(jsonProvider.id()).toBe('evals-json-rubric-provider');
|
|
expect(jsonResult.invoice_id).toBe('inv-123');
|
|
expect(jsonResult.status).toBe('approved');
|
|
|
|
const evalGrader = await loadFixtureProvider('evals-json-rubric/grader.mjs');
|
|
const evalGrade = parseOutputJson(
|
|
await evalGrader.callApi('The answer mentions inv-123, approved, and low risk.'),
|
|
);
|
|
expect(evalGrade.pass).toBe(true);
|
|
expect(evalGrade.score).toBe(1);
|
|
|
|
const localWrapper = await loadFixtureProvider('provider-setup-local-js/provider.mjs');
|
|
const localResult = await localWrapper.callApi('Say exactly PONG.', {
|
|
vars: { user_id: 'qa-static', invoice_id: 'inv-123' },
|
|
});
|
|
expect(localWrapper.id()).toBe('provider-setup-local-wrapper');
|
|
expect(localResult.output).toBe('PONG local wrapper for qa-static/inv-123');
|
|
expect(localResult.metadata).toEqual({ user_id: 'qa-static', invoice_id: 'inv-123' });
|
|
|
|
const configuredWrapper = await loadFixtureProvider('provider-setup-local-js/provider.mjs', {
|
|
config: { defaultUserId: 'qa-config', defaultInvoiceId: 'inv-config' },
|
|
});
|
|
const configuredResult = await configuredWrapper.callApi('Say exactly PONG.', { vars: {} });
|
|
expect(configuredResult.output).toBe('PONG local wrapper for qa-config/inv-config');
|
|
expect(configuredResult.metadata).toEqual({ user_id: 'qa-config', invoice_id: 'inv-config' });
|
|
|
|
const pythonOutput = execFileSync(fixturePythonExecutable, ['provider.py'], {
|
|
cwd: path.join(fixtureRoot, 'provider-setup-local-python'),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' },
|
|
});
|
|
const pythonResult = JSON.parse(pythonOutput) as ProviderResult;
|
|
expect(pythonResult.output).toBe('PONG python wrapper for qa-static/inv-123');
|
|
expect(pythonResult.metadata).toEqual({ user_id: 'qa-static', invoice_id: 'inv-123' });
|
|
|
|
const singleGenerator = await loadFixtureProvider(
|
|
'redteam-setup-single-input/redteam-generator.mjs',
|
|
);
|
|
expect((await singleGenerator.callApi()).output).toContain('Prompt:');
|
|
|
|
const multiGenerator = await loadFixtureProvider(
|
|
'redteam-setup-multi-input/redteam-generator.mjs',
|
|
);
|
|
expect((await multiGenerator.callApi()).output).toContain('<Prompt>{"user_id"');
|
|
|
|
const liveHttpGenerator = await loadFixtureProvider(
|
|
'redteam-setup-live-http/redteam-generator.mjs',
|
|
);
|
|
expect((await liveHttpGenerator.callApi()).output).toContain('trip-owned-by-victim');
|
|
const openApiGenerator = await loadFixtureProvider(
|
|
'redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
);
|
|
expect((await openApiGenerator.callApi()).output).toContain('tenant-owned-by-victim');
|
|
|
|
const staticTarget = await loadFixtureProvider('redteam-setup-static-code/target.mjs');
|
|
const staticResult = await staticTarget.callApi('Show me the private invoice.', {
|
|
vars: {
|
|
user_id: 'attacker-user',
|
|
invoice_id: 'invoice-owned-by-victim',
|
|
message: 'Show me the private invoice.',
|
|
},
|
|
});
|
|
expect(staticResult.output).toContain('DENIED invoice invoice-owned-by-victim');
|
|
expect(staticResult.metadata).toEqual(
|
|
expect.objectContaining({
|
|
route: '/api/invoices/:invoice_id/chat',
|
|
authHeader: 'Authorization',
|
|
}),
|
|
);
|
|
|
|
const configuredStaticTarget = await loadFixtureProvider(
|
|
'redteam-setup-static-code/target.mjs',
|
|
{
|
|
config: {
|
|
defaultUserId: 'qa-static-config',
|
|
defaultInvoiceId: 'invoice-static-config',
|
|
},
|
|
},
|
|
);
|
|
const staticConfigResult = await configuredStaticTarget.callApi('Health check', { vars: {} });
|
|
expect(staticConfigResult.output).toContain(
|
|
'Invoice invoice-static-config response for qa-static-config',
|
|
);
|
|
expect(staticConfigResult.metadata).toEqual(
|
|
expect.objectContaining({
|
|
user_id: 'qa-static-config',
|
|
invoice_id: 'invoice-static-config',
|
|
}),
|
|
);
|
|
|
|
const staticGenerator = await loadFixtureProvider(
|
|
'redteam-setup-static-code/redteam-generator.mjs',
|
|
);
|
|
expect((await staticGenerator.callApi()).output).toContain('invoice-owned-by-victim');
|
|
|
|
const pythonTargetOutput = execFileSync(fixturePythonExecutable, ['target.py'], {
|
|
cwd: path.join(fixtureRoot, 'redteam-setup-static-code-python'),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' },
|
|
});
|
|
const pythonTargetResult = JSON.parse(pythonTargetOutput) as ProviderResult;
|
|
expect(pythonTargetResult.output).toContain('DENIED invoice invoice-owned-by-victim');
|
|
expect(pythonTargetResult.metadata).toEqual(
|
|
expect.objectContaining({
|
|
route: '/api/invoices/{invoice_id}/chat',
|
|
authHeader: 'Authorization',
|
|
}),
|
|
);
|
|
|
|
const pythonConfigOutput = execFileSync(
|
|
fixturePythonExecutable,
|
|
[
|
|
'-c',
|
|
[
|
|
'import json, runpy',
|
|
'ns = runpy.run_path("target.py")',
|
|
'print(json.dumps(ns["invoice_redteam_target"]("Health check", {"config": {"defaultUserId": "qa-py-static-config", "defaultInvoiceId": "invoice-py-static-config"}}, {"vars": {}}), sort_keys=True))',
|
|
].join('; '),
|
|
],
|
|
{
|
|
cwd: path.join(fixtureRoot, 'redteam-setup-static-code-python'),
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
OAIPKG_DISABLE_META_MISSING: '1',
|
|
PYTHONSAFEPATH: '1',
|
|
},
|
|
},
|
|
);
|
|
const pythonConfigResult = JSON.parse(pythonConfigOutput) as ProviderResult;
|
|
expect(pythonConfigResult.output).toContain(
|
|
'Invoice invoice-py-static-config response for qa-py-static-config',
|
|
);
|
|
expect(pythonConfigResult.metadata).toEqual(
|
|
expect.objectContaining({
|
|
user_id: 'qa-py-static-config',
|
|
invoice_id: 'invoice-py-static-config',
|
|
}),
|
|
);
|
|
|
|
const pythonGeneratorOutput = execFileSync(fixturePythonExecutable, ['redteam-generator.py'], {
|
|
cwd: path.join(fixtureRoot, 'redteam-setup-static-code-python'),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' },
|
|
});
|
|
const pythonGeneratorResult = JSON.parse(pythonGeneratorOutput) as ProviderResult;
|
|
expect(pythonGeneratorResult.output).toContain('invoice-owned-by-victim');
|
|
|
|
const passTarget = await loadFixtureProvider('redteam-run-local-pass/target.mjs');
|
|
const passResult = await passTarget.callApi('Show me another user private itinerary.', {
|
|
vars: { user_id: 'qa-user' },
|
|
});
|
|
expect(passResult.output).toContain('DENIED');
|
|
expect(passResult.error).toBeUndefined();
|
|
|
|
const configuredPassTarget = await loadFixtureProvider('redteam-run-local-pass/target.mjs', {
|
|
config: { defaultUserId: 'qa-run-config' },
|
|
});
|
|
const configuredPassResult = await configuredPassTarget.callApi('Health check', { vars: {} });
|
|
expect(configuredPassResult.output).toContain('for qa-run-config');
|
|
|
|
const passGrader = await loadFixtureProvider('redteam-run-local-pass/grader.mjs');
|
|
expect(parseOutputJson(await passGrader.callApi(String(passResult.output))).pass).toBe(true);
|
|
|
|
const pythonRunTargetOutput = execFileSync(fixturePythonExecutable, ['target.py'], {
|
|
cwd: path.join(fixtureRoot, 'redteam-run-local-python-pass'),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' },
|
|
});
|
|
const pythonRunTargetResult = JSON.parse(pythonRunTargetOutput) as ProviderResult;
|
|
expect(pythonRunTargetResult.output).toContain('DENIED');
|
|
const pythonRunConfigOutput = execFileSync(
|
|
fixturePythonExecutable,
|
|
[
|
|
'-c',
|
|
[
|
|
'import json, runpy',
|
|
'ns = runpy.run_path("target.py")',
|
|
'print(json.dumps(ns["call_api"]("Health check", {"config": {"defaultUserId": "qa-py-run-config"}}, {"vars": {}}), sort_keys=True))',
|
|
].join('; '),
|
|
],
|
|
{
|
|
cwd: path.join(fixtureRoot, 'redteam-run-local-python-pass'),
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
OAIPKG_DISABLE_META_MISSING: '1',
|
|
PYTHONSAFEPATH: '1',
|
|
},
|
|
},
|
|
);
|
|
const pythonRunConfigResult = JSON.parse(pythonRunConfigOutput) as ProviderResult;
|
|
expect(pythonRunConfigResult.output).toContain('for qa-py-run-config');
|
|
const pythonRunGraderOutput = execFileSync(fixturePythonExecutable, ['grader.py'], {
|
|
cwd: path.join(fixtureRoot, 'redteam-run-local-python-pass'),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, OAIPKG_DISABLE_META_MISSING: '1' },
|
|
});
|
|
const pythonRunGraderResult = JSON.parse(pythonRunGraderOutput) as ProviderResult;
|
|
expect(parseOutputJson(pythonRunGraderResult).pass).toBe(true);
|
|
|
|
const mixedTarget = await loadFixtureProvider('redteam-run-local-mixed/target.mjs');
|
|
const mixedLeak = await mixedTarget.callApi('force-leak trip-owned-by-victim', {
|
|
vars: { trip_id: 'trip-owned-by-victim' },
|
|
});
|
|
expect(mixedLeak.output).toContain('INTENTIONAL_LEAK');
|
|
const mixedGrader = await loadFixtureProvider('redteam-run-local-mixed/grader.mjs');
|
|
expect(parseOutputJson(await mixedGrader.callApi(String(mixedLeak.output))).pass).toBe(false);
|
|
|
|
const errorTarget = await loadFixtureProvider('redteam-run-local-error/target.mjs');
|
|
const errorResult = await errorTarget.callApi('target-error while checking trip.', {
|
|
vars: { trip_id: 'trip-owned-by-victim' },
|
|
});
|
|
expect(errorResult.error).toContain('Simulated target outage');
|
|
const errorGrader = await loadFixtureProvider('redteam-run-local-error/grader.mjs');
|
|
expect(parseOutputJson(await errorGrader.callApi('No protected data exposed.')).pass).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('promptfoo-evals skill', () => {
|
|
it('has a routing description with eval scope and provider/redteam boundaries', () => {
|
|
const skill = readText(path.join(evalsSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toMatch(/^---\nname: promptfoo-evals\n/);
|
|
expect(skill).toContain('after the target');
|
|
expect(skill).toContain('or provider already works');
|
|
expect(skill).toContain('prompts, vars, test cases, assertions');
|
|
expect(skill).toContain('model-graded rubrics');
|
|
expect(skill).toContain('non-redteam promptfoo eval suites');
|
|
expect(skill).toContain('connecting a new target/provider');
|
|
expect(skill).toContain('smoke-testing');
|
|
expect(skill).toContain('redteam plugin/strategy setup');
|
|
});
|
|
|
|
it('documents focused eval authoring, running, and iteration', () => {
|
|
const skill = readText(path.join(evalsSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toContain('State the eval question');
|
|
expect(skill).toContain('Choose assertions');
|
|
expect(skill).toContain('Search for existing configs first');
|
|
expect(skill).toContain('tests: file://tests/*.yaml');
|
|
expect(skill).toContain('file://prompts/');
|
|
expect(skill).toContain('Field order');
|
|
expect(skill).toContain('llms-full.txt');
|
|
expect(skill).toContain('is-json');
|
|
expect(skill).toContain('javascript');
|
|
expect(skill).toContain('llm-rubric');
|
|
expect(skill).toContain('inline the source');
|
|
expect(skill).toContain('validate config');
|
|
expect(skill).toContain('--no-cache --no-share');
|
|
expect(skill).toContain('results.stats');
|
|
expect(skill).toContain('--filter-failing');
|
|
});
|
|
|
|
it('ships Codex UI metadata for evals', () => {
|
|
const openaiYaml = readText(path.join(evalsSkillRoot, 'agents', 'openai.yaml'));
|
|
|
|
expect(openaiYaml).toContain('Promptfoo Evals');
|
|
expect(openaiYaml).toContain('$promptfoo-evals');
|
|
expect(openaiYaml).toContain('allow_implicit_invocation: true');
|
|
});
|
|
|
|
it('keeps eval patterns in a progressive-disclosure reference file', () => {
|
|
const reference = readText(path.join(evalsSkillRoot, 'references', 'eval-patterns.md'));
|
|
|
|
expect(reference).toContain('Config Structure');
|
|
expect(reference).toContain('Minimal Local Provider Eval');
|
|
expect(reference).toContain('Known Provider Examples');
|
|
expect(reference).toContain('Path(__file__).resolve().parent');
|
|
expect(reference).toContain('anchor `sys.path`');
|
|
expect(reference).toContain('constructor `options.config`');
|
|
expect(reference).toContain('options` argument to `call_api`');
|
|
expect(reference).toContain('openai:chat:gpt-4.1-mini');
|
|
expect(reference).toContain('anthropic:messages:claude-sonnet-4-6');
|
|
expect(reference).toContain('echo');
|
|
expect(reference).toContain('promptfoo-provider-setup');
|
|
expect(reference).toContain('File-Based Tests');
|
|
expect(reference).toContain('Dataset-Backed Tests');
|
|
expect(reference).toContain('tests: file://tests.csv');
|
|
expect(reference).toContain('tests: file://generate_tests.py:create_tests');
|
|
expect(reference).toContain('Assertion Scoring Options');
|
|
expect(reference).toContain('weight: 2');
|
|
expect(reference).toContain('metric: decision_accuracy');
|
|
expect(reference).toContain('For `cost` and `latency`, it is a maximum allowed value');
|
|
expect(reference).toContain('Structured JSON Eval');
|
|
expect(reference).toContain('Local Model-Graded Rubric');
|
|
expect(reference).toContain('Faithfulness Rubric');
|
|
expect(reference).toContain('{{env.OPENAI_API_KEY}}');
|
|
expect(reference).toContain('output.replace');
|
|
expect(reference).toContain('Focused Reruns');
|
|
expect(reference).toContain('PROMPTFOO_FAILED_TEST_EXIT_CODE=0');
|
|
expect(reference).toContain('pass');
|
|
expect(reference).toContain('score');
|
|
expect(reference).toContain('reason');
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
dir: 'evals-local-js',
|
|
providerFile: 'provider.mjs',
|
|
snippets: [
|
|
'description: Evals local JavaScript provider smoke',
|
|
'file://./provider.mjs',
|
|
'defaultTopic: config-topic',
|
|
'type: contains',
|
|
'type: regex',
|
|
'type: javascript',
|
|
'area: billing',
|
|
],
|
|
extraFile: undefined,
|
|
},
|
|
{
|
|
dir: 'evals-local-python',
|
|
providerFile: 'provider.py',
|
|
snippets: [
|
|
'description: Evals local Python provider smoke',
|
|
'file://./provider.py:call_api',
|
|
'workers: 1',
|
|
'timeout: 30000',
|
|
'defaultTopic: py-config-topic',
|
|
'type: contains',
|
|
'type: regex',
|
|
'type: javascript',
|
|
'area: billing',
|
|
],
|
|
extraFile: undefined,
|
|
},
|
|
{
|
|
dir: 'evals-json-rubric',
|
|
providerFile: 'provider.mjs',
|
|
snippets: [
|
|
'description: Evals JSON contract and rubric smoke',
|
|
'file://./provider.mjs',
|
|
'file://./grader.mjs',
|
|
'type: is-json',
|
|
'type: contains-any',
|
|
'type: llm-rubric',
|
|
'JSON.parse(output)',
|
|
],
|
|
extraFile: 'grader.mjs',
|
|
},
|
|
])('ships a runnable/validatable $dir eval fixture', ({
|
|
dir,
|
|
snippets,
|
|
extraFile,
|
|
providerFile,
|
|
}) => {
|
|
const config = readText(path.join(fixtureRoot, dir, 'promptfooconfig.yaml'));
|
|
const provider = readText(path.join(fixtureRoot, dir, providerFile));
|
|
|
|
expect(config).toContain(
|
|
'# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json',
|
|
);
|
|
expect(config).toContain('prompts:');
|
|
expect(config).toContain('providers:');
|
|
expect(config).toContain('tests:');
|
|
if (providerFile.endsWith('.py')) {
|
|
expect(provider).toContain('def call_api(prompt: str, options: dict, context: dict)');
|
|
} else {
|
|
expect(provider).toContain('export default class');
|
|
expect(provider).toContain('async callApi');
|
|
}
|
|
|
|
if (extraFile) {
|
|
const grader = readText(path.join(fixtureRoot, dir, extraFile));
|
|
expect(grader).toContain('export default class');
|
|
expect(grader).toContain('JSON.stringify');
|
|
expect(grader).toContain('pass');
|
|
expect(grader).toContain('score');
|
|
expect(grader).toContain('reason');
|
|
}
|
|
|
|
for (const snippet of snippets) {
|
|
expect(config).toContain(snippet);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('promptfoo-provider-setup skill', () => {
|
|
it('has a routing description with positive scope and negative boundaries', () => {
|
|
const skill = readText(path.join(providerSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toMatch(/^---\nname: promptfoo-provider-setup\n/);
|
|
expect(skill).toContain('live HTTP');
|
|
expect(skill).toContain('local scripts');
|
|
expect(skill).toContain('static-code-derived provider');
|
|
expect(skill).toContain('wrappers');
|
|
expect(skill).toContain('Do not');
|
|
expect(skill).toContain('red team plugins');
|
|
});
|
|
|
|
it('documents live, static, hybrid, and wrapper setup workflows', () => {
|
|
const skill = readText(path.join(providerSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toContain('Live HTTP endpoint');
|
|
expect(skill).toContain('Static code discovery');
|
|
expect(skill).toContain('Hybrid');
|
|
expect(skill).toContain('Wrapper mode');
|
|
expect(skill).toContain('transformResponse');
|
|
expect(skill).toContain('stateful: false');
|
|
expect(skill).toContain('query-string fields on any HTTP method');
|
|
expect(skill).toContain('--auth-header');
|
|
expect(skill).toContain('--auth-prefix');
|
|
expect(skill).toContain('infers Bearer/OAuth2/OpenID and header/query/cookie API-key auth');
|
|
expect(skill).toContain('constructor `options.config`');
|
|
expect(skill).toContain('file://provider.py:function_name');
|
|
expect(skill).toContain('config.timeout');
|
|
expect(skill).toContain('PROMPTFOO_PYTHON');
|
|
expect(skill).toContain('validate target');
|
|
expect(skill).toContain('targets');
|
|
expect(skill).toContain('inputs');
|
|
expect(skill).toContain('--no-cache --no-share');
|
|
expect(skill).toContain('Inspect the output file for `results.stats`, `response.output`');
|
|
});
|
|
|
|
it('ships Codex UI metadata with an explicit skill mention', () => {
|
|
const openaiYaml = readText(path.join(providerSkillRoot, 'agents', 'openai.yaml'));
|
|
|
|
expect(openaiYaml).toContain('Promptfoo Provider Setup');
|
|
expect(openaiYaml).toContain('$promptfoo-provider-setup');
|
|
expect(openaiYaml).toContain('allow_implicit_invocation: true');
|
|
});
|
|
|
|
it('keeps provider examples in a progressive-disclosure reference file', () => {
|
|
const reference = readText(path.join(providerSkillRoot, 'references', 'provider-patterns.md'));
|
|
|
|
expect(reference).toContain('id: https');
|
|
expect(reference).toContain('queryParams:');
|
|
expect(reference).toContain('json.choices[0].message.content');
|
|
expect(reference).toContain('transformResponse: text.replace');
|
|
expect(reference).toContain('stateful: false');
|
|
expect(reference).toContain('{{sessionId}}');
|
|
expect(reference).toContain('file://provider.js');
|
|
expect(reference).toContain('file://provider.py:call_api');
|
|
expect(reference).toContain('file://provider.py:function_name');
|
|
expect(reference).toContain('targets:');
|
|
expect(reference).toContain('inputs:');
|
|
expect(reference).toContain('constructor(options = {})');
|
|
expect(reference).toContain('this.config = options.config || {}');
|
|
expect(reference).toContain('callApi(prompt, context = {})');
|
|
expect(reference).toContain('call_api(prompt: str, options: dict, context: dict)');
|
|
expect(reference).toContain('sys.path.insert(0, str(Path(__file__).resolve().parent))');
|
|
expect(reference).toContain('Anchor');
|
|
expect(reference).toContain('PROMPTFOO_PYTHON');
|
|
expect(reference).toContain('PROMPTFOO_PYTHON_WORKERS');
|
|
expect(reference).toContain('config.pythonExecutable');
|
|
expect(reference).toContain('config.workers');
|
|
expect(reference).toContain('config.timeout');
|
|
expect(reference).toContain('context.vars');
|
|
expect(reference).toContain('{{env.CHAT_API_URL}}');
|
|
expect(reference).toContain('OpenAPI operation to HTTP provider');
|
|
expect(reference).toContain('operation at a time');
|
|
expect(reference).toContain('path parameters into the');
|
|
expect(reference).toContain('first successful response schema');
|
|
expect(reference).toContain('OpenAPI `$ref`s');
|
|
expect(reference).toContain('`allOf`');
|
|
expect(reference).toContain('`oneOf`/`anyOf`');
|
|
expect(reference).toContain('wire names intact');
|
|
expect(reference).toContain('safe vars');
|
|
expect(reference).toContain('preserves headers');
|
|
expect(reference).toContain('parameter/media examples');
|
|
expect(reference).toContain('+json media');
|
|
expect(reference).toContain('defaults/enums');
|
|
expect(reference).toContain('health/status');
|
|
expect(reference).toContain('`question`');
|
|
expect(reference).toContain('`input`');
|
|
expect(reference).toContain('Bearer/OAuth2/OpenID/header/query/cookie API-key');
|
|
expect(reference).toContain('--auth-header X-API-Key --auth-prefix none');
|
|
expect(reference).toContain('Hybrid discovery notes');
|
|
expect(reference).toContain('Static source: route/handler/client file and line range');
|
|
expect(reference).toContain('Safe live probe: exact non-mutating payload');
|
|
expect(reference).toContain('Promptfoo mapping: vars to request fields');
|
|
expect(reference).toContain('--no-cache');
|
|
expect(reference).toContain('--no-share');
|
|
expect(reference).toContain('rg -n');
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
dir: 'provider-setup-http',
|
|
snippets: ['method: POST', 'stateful: false', 'body:', 'transformResponse: json.output'],
|
|
},
|
|
{
|
|
dir: 'provider-setup-http-get',
|
|
snippets: [
|
|
'method: GET',
|
|
'stateful: false',
|
|
'queryParams:',
|
|
'transformResponse: json.answer',
|
|
],
|
|
},
|
|
{
|
|
dir: 'provider-setup-http-openai-compatible',
|
|
snippets: [
|
|
'stateful: false',
|
|
'messages:',
|
|
'json.choices[0].message.content',
|
|
'Authorization:',
|
|
],
|
|
},
|
|
{
|
|
dir: 'provider-setup-http-text',
|
|
snippets: ['stateful: false', 'Content-Type: text/plain', 'transformResponse: text.replace'],
|
|
},
|
|
{
|
|
dir: 'provider-setup-local-js',
|
|
snippets: [
|
|
'file://provider.mjs',
|
|
'defaultUserId: qa-config',
|
|
'invoice_id:',
|
|
'local wrapper reaches static app code',
|
|
],
|
|
},
|
|
{
|
|
dir: 'provider-setup-local-python',
|
|
snippets: [
|
|
'file://provider.py:invoice_agent_provider',
|
|
'workers: 1',
|
|
'timeout: 30000',
|
|
'defaultUserId: qa-py-config',
|
|
'local Python wrapper reaches static app code',
|
|
],
|
|
},
|
|
{
|
|
dir: 'provider-setup-openapi',
|
|
snippets: [
|
|
'provider-setup-openapi-invoice-chat',
|
|
'/v1/invoices/{{invoice_id | urlencode}}/chat',
|
|
'Authorization:',
|
|
'transformResponse: json.output',
|
|
],
|
|
},
|
|
{
|
|
dir: 'provider-setup-redteam-target',
|
|
snippets: ['targets:', 'inputs:', 'stateful: false', 'user_id:', 'plugins:', 'strategies:'],
|
|
},
|
|
])('ships a runnable/validatable $dir fixture', ({ dir, snippets }) => {
|
|
const config = readText(path.join(fixtureRoot, dir, 'promptfooconfig.yaml'));
|
|
|
|
expect(config).toContain(
|
|
'# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json',
|
|
);
|
|
expect(config).toContain('description: Provider setup');
|
|
|
|
for (const snippet of snippets) {
|
|
expect(config).toContain(snippet);
|
|
}
|
|
});
|
|
|
|
it('ships a local JavaScript wrapper fixture that imports static app code', () => {
|
|
const provider = readText(path.join(fixtureRoot, 'provider-setup-local-js', 'provider.mjs'));
|
|
const app = readText(
|
|
path.join(fixtureRoot, 'provider-setup-local-js', 'app', 'invoiceAgent.mjs'),
|
|
);
|
|
const routes = readText(path.join(fixtureRoot, 'provider-setup-local-js', 'app', 'routes.mjs'));
|
|
|
|
expect(provider).toContain("import { invoiceAgent } from './app/invoiceAgent.mjs'");
|
|
expect(provider).toContain("import { invoiceSupportRoute } from './app/routes.mjs'");
|
|
expect(provider).toContain('async callApi(prompt, context = {})');
|
|
expect(provider).toContain('context.vars');
|
|
expect(provider).toContain('invoiceSupportRoute.safeDefaults.userId');
|
|
expect(app).toContain('export async function invoiceAgent');
|
|
expect(app).toContain('PONG local wrapper');
|
|
expect(routes).toContain("method: 'POST'");
|
|
expect(routes).toContain("path: '/api/invoices/:invoice_id/chat'");
|
|
expect(routes).toContain("authHeader: 'Authorization'");
|
|
expect(routes).toContain("bodyFields: ['user_id', 'invoice_id', 'message']");
|
|
expect(routes).toContain("responsePath: 'output'");
|
|
expect(routes).toContain('router.post(invoiceSupportRoute.path');
|
|
});
|
|
|
|
it('ships a local Python wrapper fixture that imports static app code', () => {
|
|
const provider = readText(path.join(fixtureRoot, 'provider-setup-local-python', 'provider.py'));
|
|
const app = readText(
|
|
path.join(fixtureRoot, 'provider-setup-local-python', 'app', 'invoice_agent.py'),
|
|
);
|
|
const routes = readText(
|
|
path.join(fixtureRoot, 'provider-setup-local-python', 'app', 'routes.py'),
|
|
);
|
|
|
|
expect(provider).toContain('from app.invoice_agent import invoice_agent');
|
|
expect(provider).toContain('from app.routes import INVOICE_SUPPORT_ROUTE');
|
|
expect(provider).toContain('sys.path.insert(0, str(Path(__file__).resolve().parent))');
|
|
expect(provider).toContain(
|
|
'def invoice_agent_provider(prompt: str, options: dict, context: dict) -> dict:',
|
|
);
|
|
expect(provider).toContain('vars = _dict(_dict(context).get("vars"))');
|
|
expect(provider).toContain('INVOICE_SUPPORT_ROUTE["safe_defaults"]');
|
|
expect(app).toContain('def invoice_agent(user_id: str, invoice_id: str, message: str)');
|
|
expect(app).toContain('PONG python wrapper');
|
|
expect(routes).toContain('"method": "POST"');
|
|
expect(routes).toContain('"/api/invoices/{invoice_id}/chat"');
|
|
expect(routes).toContain('"auth_header": "Authorization"');
|
|
expect(routes).toContain('"body_fields": ["user_id", "invoice_id", "message"]');
|
|
expect(routes).toContain('"response_path": "output"');
|
|
});
|
|
|
|
it('ships an OpenAPI-derived provider fixture with matching operation mapping', () => {
|
|
const config = yaml.load(
|
|
readText(path.join(fixtureRoot, 'provider-setup-openapi', 'promptfooconfig.yaml')),
|
|
);
|
|
const spec = yaml.load(
|
|
readText(path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml')),
|
|
);
|
|
|
|
expectRecord(config, 'OpenAPI provider config');
|
|
expectRecord(spec, 'OpenAPI spec');
|
|
expectRecord(spec.paths, 'OpenAPI paths');
|
|
const operation = (spec.paths as Record<string, unknown>)['/v1/invoices/{invoice_id}/chat'];
|
|
expectRecord(operation, 'OpenAPI path item');
|
|
expectRecord(operation.post, 'OpenAPI post operation');
|
|
expectRecord(spec.components, 'OpenAPI components');
|
|
expectRecord(spec.components.schemas, 'OpenAPI component schemas');
|
|
expectRecord(spec.components.parameters, 'OpenAPI component parameters');
|
|
expectRecord(spec.components.requestBodies, 'OpenAPI component request bodies');
|
|
expectRecord(spec.components.responses, 'OpenAPI component responses');
|
|
expectRecord(spec.components.securitySchemes, 'OpenAPI component security schemes');
|
|
expect(spec.components.securitySchemes.invoiceApiKey).toEqual({
|
|
type: 'apiKey',
|
|
in: 'header',
|
|
name: 'X-API-Key',
|
|
});
|
|
expect(spec.components.securitySchemes.invoiceQueryApiKey).toEqual({
|
|
type: 'apiKey',
|
|
in: 'query',
|
|
name: 'api_key',
|
|
});
|
|
expect(spec.components.securitySchemes.invoiceCookieApiKey).toEqual({
|
|
type: 'apiKey',
|
|
in: 'cookie',
|
|
name: 'invoice_session',
|
|
});
|
|
|
|
const provider = (config.providers as unknown[])[0];
|
|
expectRecord(provider, 'OpenAPI provider');
|
|
expectRecord(provider.config, 'OpenAPI provider config block');
|
|
|
|
expect(operation.post.operationId).toBe('chatWithInvoice');
|
|
expect(operation.post.requestBody).toEqual({
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/InvoiceChatRequest' },
|
|
},
|
|
},
|
|
});
|
|
expect(spec.components.parameters.SearchQuery).toEqual(
|
|
expect.objectContaining({
|
|
name: 'q',
|
|
in: 'query',
|
|
}),
|
|
);
|
|
expect(spec.components.parameters.InvoiceId).toEqual(
|
|
expect.objectContaining({
|
|
name: 'invoice_id',
|
|
in: 'path',
|
|
}),
|
|
);
|
|
expect(spec.components.parameters.TenantId).toEqual(
|
|
expect.objectContaining({
|
|
name: 'tenant_id',
|
|
in: 'query',
|
|
}),
|
|
);
|
|
expect(spec.components.parameters.TenantHeader).toEqual(
|
|
expect.objectContaining({
|
|
name: 'X-Tenant-Id',
|
|
in: 'header',
|
|
}),
|
|
);
|
|
expect(spec.components.parameters.ApiVersion).toEqual(
|
|
expect.objectContaining({
|
|
name: 'api-version',
|
|
in: 'query',
|
|
}),
|
|
);
|
|
const noteOperation = (spec.paths as Record<string, unknown>)[
|
|
'/v1/invoices/{invoice_id}/notes'
|
|
];
|
|
expectRecord(noteOperation, 'OpenAPI note path item');
|
|
expect(noteOperation.parameters).toEqual([
|
|
{ $ref: '#/components/parameters/InvoiceId' },
|
|
{ $ref: '#/components/parameters/TenantId' },
|
|
]);
|
|
expectRecord(noteOperation.post, 'OpenAPI note operation');
|
|
expect(noteOperation.post.requestBody).toEqual({
|
|
$ref: '#/components/requestBodies/InvoiceNoteRequest',
|
|
});
|
|
expect(noteOperation.post.responses).toEqual({
|
|
'201': { $ref: '#/components/responses/CreatedInvoiceNote' },
|
|
});
|
|
const questionOperation = (spec.paths as Record<string, unknown>)['/v1/invoices/ask'];
|
|
expectRecord(questionOperation, 'OpenAPI question path item');
|
|
expectRecord(questionOperation.post, 'OpenAPI question operation');
|
|
expect(questionOperation.post.operationId).toBe('askInvoiceQuestion');
|
|
expect(questionOperation.post.requestBody).toEqual({
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/InvoiceQuestionRequest' },
|
|
},
|
|
},
|
|
});
|
|
expect(provider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/{{invoice_id | urlencode}}/chat',
|
|
);
|
|
expect(provider.config.method).toBe('POST');
|
|
expect(provider.config.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: 'Bearer {{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
}),
|
|
);
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.output');
|
|
});
|
|
|
|
it('ships an OpenAPI operation helper script that drafts a provider config', () => {
|
|
const output = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'chatWithInvoice',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_TOKEN',
|
|
'--label',
|
|
'generated-openapi-invoice-chat',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generated = yaml.load(output);
|
|
|
|
expect(output).toContain(
|
|
'# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json',
|
|
);
|
|
expectRecord(generated, 'Generated OpenAPI config');
|
|
const provider = (generated.providers as unknown[])[0];
|
|
expectRecord(provider, 'Generated OpenAPI provider');
|
|
expectRecord(provider.config, 'Generated OpenAPI provider config');
|
|
expect(provider.label).toBe('generated-openapi-invoice-chat');
|
|
expect(provider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/{{invoice_id | urlencode}}/chat',
|
|
);
|
|
expect(provider.config.headers).toEqual(
|
|
expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
Authorization: 'Bearer {{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
}),
|
|
);
|
|
expect(provider.config.body).toEqual({ user_id: '{{user_id}}', message: '{{prompt}}' });
|
|
expect(provider.config.transformResponse).toBe('json.output');
|
|
|
|
const getOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'searchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_TOKEN',
|
|
'--label',
|
|
'generated-openapi-invoice-search',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generatedGet = yaml.load(getOutput);
|
|
expectRecord(generatedGet, 'Generated OpenAPI GET config');
|
|
const getProvider = (generatedGet.providers as unknown[])[0];
|
|
expectRecord(getProvider, 'Generated OpenAPI GET provider');
|
|
expectRecord(getProvider.config, 'Generated OpenAPI GET provider config');
|
|
expect(getProvider.config.method).toBe('GET');
|
|
expect(getProvider.config.url).toBe('{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/search');
|
|
expect(getProvider.config.body).toBeUndefined();
|
|
expect(getProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(getProvider.config.transformResponse).toBe('json.answer');
|
|
const getTest = (generatedGet.tests as unknown[])[0];
|
|
expectRecord(getTest, 'Generated OpenAPI GET test');
|
|
expect(getTest.vars).toEqual({
|
|
user_id: 'sample-user-id',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
|
|
const apiKeyOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'searchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_KEY',
|
|
'--auth-header',
|
|
'X-API-Key',
|
|
'--auth-prefix',
|
|
'none',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generatedApiKey = yaml.load(apiKeyOutput);
|
|
expectRecord(generatedApiKey, 'Generated OpenAPI API key config');
|
|
const apiKeyProvider = (generatedApiKey.providers as unknown[])[0];
|
|
expectRecord(apiKeyProvider, 'Generated OpenAPI API key provider');
|
|
expectRecord(apiKeyProvider.config, 'Generated OpenAPI API key provider config');
|
|
expect(apiKeyProvider.config.headers).toEqual({
|
|
'X-API-Key': '{{env.OPENAPI_INVOICE_API_KEY}}',
|
|
});
|
|
|
|
const inferredApiKeyOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'apiKeySearchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_KEY',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const inferredApiKeyGenerated = yaml.load(inferredApiKeyOutput);
|
|
expectRecord(inferredApiKeyGenerated, 'Generated inferred OpenAPI API key config');
|
|
const inferredApiKeyProvider = (inferredApiKeyGenerated.providers as unknown[])[0];
|
|
expectRecord(inferredApiKeyProvider, 'Generated inferred OpenAPI API key provider');
|
|
expectRecord(
|
|
inferredApiKeyProvider.config,
|
|
'Generated inferred OpenAPI API key provider config',
|
|
);
|
|
expect(inferredApiKeyProvider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/apikey-search',
|
|
);
|
|
expect(inferredApiKeyProvider.config.headers).toEqual({
|
|
'X-API-Key': '{{env.OPENAPI_INVOICE_API_KEY}}',
|
|
});
|
|
|
|
const inferredQueryApiKeyOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'queryApiKeySearchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_KEY',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const inferredQueryApiKeyGenerated = yaml.load(inferredQueryApiKeyOutput);
|
|
expectRecord(inferredQueryApiKeyGenerated, 'Generated inferred OpenAPI query API key config');
|
|
const inferredQueryApiKeyProvider = (inferredQueryApiKeyGenerated.providers as unknown[])[0];
|
|
expectRecord(inferredQueryApiKeyProvider, 'Generated inferred OpenAPI query API key provider');
|
|
expectRecord(
|
|
inferredQueryApiKeyProvider.config,
|
|
'Generated inferred OpenAPI query API key provider config',
|
|
);
|
|
expect(inferredQueryApiKeyProvider.config.headers).toBeUndefined();
|
|
expect(inferredQueryApiKeyProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
api_key: '{{env.OPENAPI_INVOICE_API_KEY}}',
|
|
});
|
|
|
|
const inferredCookieOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'cookieSearchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_SESSION',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const inferredCookieGenerated = yaml.load(inferredCookieOutput);
|
|
expectRecord(inferredCookieGenerated, 'Generated inferred OpenAPI cookie config');
|
|
const inferredCookieProvider = (inferredCookieGenerated.providers as unknown[])[0];
|
|
expectRecord(inferredCookieProvider, 'Generated inferred OpenAPI cookie provider');
|
|
expectRecord(
|
|
inferredCookieProvider.config,
|
|
'Generated inferred OpenAPI cookie provider config',
|
|
);
|
|
expect(inferredCookieProvider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/cookie-search',
|
|
);
|
|
expect(inferredCookieProvider.config.headers).toEqual({
|
|
Cookie: 'invoice_session={{env.OPENAPI_INVOICE_SESSION}}',
|
|
});
|
|
expect(inferredCookieProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
|
|
const conjunctiveAuthTempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'promptfoo-openapi-conjunctive-auth-'),
|
|
);
|
|
try {
|
|
const conjunctiveAuthSpecPath = path.join(conjunctiveAuthTempDir, 'openapi.yaml');
|
|
fs.writeFileSync(conjunctiveAuthSpecPath, yaml.dump(openApiConjunctiveAuthSpec()));
|
|
const conjunctiveAuthOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
'conjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const conjunctiveAuthGenerated = yaml.load(conjunctiveAuthOutput);
|
|
expectRecord(conjunctiveAuthGenerated, 'Generated OpenAPI conjunctive auth config');
|
|
const conjunctiveAuthProvider = (conjunctiveAuthGenerated.providers as unknown[])[0];
|
|
expectRecord(conjunctiveAuthProvider, 'Generated OpenAPI conjunctive auth provider');
|
|
expectRecord(
|
|
conjunctiveAuthProvider.config,
|
|
'Generated OpenAPI conjunctive auth provider config',
|
|
);
|
|
expect(conjunctiveAuthProvider.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
Cookie:
|
|
'session_id={{env.CONJUNCTIVE_API_TOKEN}}; csrf_token={{env.CONJUNCTIVE_API_TOKEN}}',
|
|
'X-API-Key': '{{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
expect(conjunctiveAuthProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
});
|
|
|
|
const fallbackConjunctiveAuthOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
'fallbackConjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const fallbackConjunctiveAuthGenerated = yaml.load(fallbackConjunctiveAuthOutput);
|
|
expectRecord(
|
|
fallbackConjunctiveAuthGenerated,
|
|
'Generated OpenAPI fallback conjunctive auth config',
|
|
);
|
|
const fallbackConjunctiveAuthProvider = (
|
|
fallbackConjunctiveAuthGenerated.providers as unknown[]
|
|
)[0];
|
|
expectRecord(
|
|
fallbackConjunctiveAuthProvider,
|
|
'Generated OpenAPI fallback conjunctive auth provider',
|
|
);
|
|
expectRecord(
|
|
fallbackConjunctiveAuthProvider.config,
|
|
'Generated OpenAPI fallback conjunctive auth provider config',
|
|
);
|
|
expect(fallbackConjunctiveAuthProvider.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
'X-API-Key': '{{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
|
|
const partialConjunctiveAuthOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
'partialConjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const partialConjunctiveAuthGenerated = yaml.load(partialConjunctiveAuthOutput);
|
|
expectRecord(
|
|
partialConjunctiveAuthGenerated,
|
|
'Generated OpenAPI partial conjunctive auth config',
|
|
);
|
|
const partialConjunctiveAuthProvider = (
|
|
partialConjunctiveAuthGenerated.providers as unknown[]
|
|
)[0];
|
|
expectRecord(
|
|
partialConjunctiveAuthProvider,
|
|
'Generated OpenAPI partial conjunctive auth provider',
|
|
);
|
|
expectRecord(
|
|
partialConjunctiveAuthProvider.config,
|
|
'Generated OpenAPI partial conjunctive auth provider config',
|
|
);
|
|
expect(partialConjunctiveAuthProvider.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
|
|
for (const operationId of [
|
|
'explicitNoAuthSearch',
|
|
'emptyRequirementSearch',
|
|
'unsupportedAuthSearch',
|
|
]) {
|
|
const noAuthOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const noAuthGenerated = yaml.load(noAuthOutput);
|
|
expectRecord(noAuthGenerated, `Generated OpenAPI ${operationId} config`);
|
|
const noAuthProvider = (noAuthGenerated.providers as unknown[])[0];
|
|
expectRecord(noAuthProvider, `Generated OpenAPI ${operationId} provider`);
|
|
expectRecord(noAuthProvider.config, `Generated OpenAPI ${operationId} provider config`);
|
|
expect(noAuthProvider.config.headers).toBeUndefined();
|
|
expect(noAuthProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
});
|
|
}
|
|
} finally {
|
|
fs.rmSync(conjunctiveAuthTempDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const oauthTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-oauth-'));
|
|
try {
|
|
const oauthSpecPath = path.join(oauthTempDir, 'openapi.yaml');
|
|
fs.writeFileSync(oauthSpecPath, yaml.dump(openApiOAuthSecuritySpec()));
|
|
const runOAuthProviderHelper = (operationId: string, tokenEnv: string) =>
|
|
yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
oauthSpecPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'OAUTH_API_BASE_URL',
|
|
'--token-env',
|
|
tokenEnv,
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
|
|
const oauthGenerated = runOAuthProviderHelper('oauthSearch', 'OAUTH_ACCESS_TOKEN');
|
|
expectRecord(oauthGenerated, 'Generated OpenAPI OAuth2 provider config');
|
|
const oauthProvider = (oauthGenerated.providers as unknown[])[0];
|
|
expectRecord(oauthProvider, 'Generated OpenAPI OAuth2 provider');
|
|
expectRecord(oauthProvider.config, 'Generated OpenAPI OAuth2 provider config block');
|
|
expect(oauthProvider.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.OAUTH_ACCESS_TOKEN}}',
|
|
});
|
|
expect(oauthProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
|
|
const openIdGenerated = runOAuthProviderHelper('openIdSearch', 'OPENID_ACCESS_TOKEN');
|
|
expectRecord(openIdGenerated, 'Generated OpenAPI OpenID provider config');
|
|
const openIdProvider = (openIdGenerated.providers as unknown[])[0];
|
|
expectRecord(openIdProvider, 'Generated OpenAPI OpenID provider');
|
|
expectRecord(openIdProvider.config, 'Generated OpenAPI OpenID provider config block');
|
|
expect(openIdProvider.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.OPENID_ACCESS_TOKEN}}',
|
|
});
|
|
expect(openIdProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
});
|
|
} finally {
|
|
fs.rmSync(oauthTempDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const headerOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'headerSearchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generatedHeader = yaml.load(headerOutput);
|
|
expectRecord(generatedHeader, 'Generated OpenAPI header parameter config');
|
|
const headerProvider = (generatedHeader.providers as unknown[])[0];
|
|
expectRecord(headerProvider, 'Generated OpenAPI header parameter provider');
|
|
expectRecord(headerProvider.config, 'Generated OpenAPI header parameter provider config');
|
|
expect(headerProvider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/header-search',
|
|
);
|
|
expect(headerProvider.config.headers).toEqual({
|
|
'X-Tenant-Id': '{{x_tenant_id}}',
|
|
});
|
|
expect(headerProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
'api-version': '{{api_version}}',
|
|
});
|
|
const headerTest = (generatedHeader.tests as unknown[])[0];
|
|
expectRecord(headerTest, 'Generated OpenAPI header parameter test');
|
|
expect(headerTest.vars).toEqual({
|
|
user_id: 'sample-user-id',
|
|
x_tenant_id: 'tenant-alpha',
|
|
api_version: '2026-04-01',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
|
|
const cookieParamOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'cookieParamSearchInvoices',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generatedCookieParam = yaml.load(cookieParamOutput);
|
|
expectRecord(generatedCookieParam, 'Generated OpenAPI cookie parameter config');
|
|
const cookieParamProvider = (generatedCookieParam.providers as unknown[])[0];
|
|
expectRecord(cookieParamProvider, 'Generated OpenAPI cookie parameter provider');
|
|
expectRecord(cookieParamProvider.config, 'Generated OpenAPI cookie parameter provider config');
|
|
expect(cookieParamProvider.config.headers).toEqual({
|
|
Cookie: 'invoice-context={{invoice_context}}',
|
|
});
|
|
expect(cookieParamProvider.config.queryParams).toEqual({
|
|
q: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
const cookieParamTest = (generatedCookieParam.tests as unknown[])[0];
|
|
expectRecord(cookieParamTest, 'Generated OpenAPI cookie parameter test');
|
|
expect(cookieParamTest.vars).toEqual({
|
|
user_id: 'sample-user-id',
|
|
invoice_context: 'context-alpha',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
|
|
const questionOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'askInvoiceQuestion',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_TOKEN',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generatedQuestion = yaml.load(questionOutput);
|
|
expectRecord(generatedQuestion, 'Generated OpenAPI question config');
|
|
const questionProvider = (generatedQuestion.providers as unknown[])[0];
|
|
expectRecord(questionProvider, 'Generated OpenAPI question provider');
|
|
expectRecord(questionProvider.config, 'Generated OpenAPI question provider config');
|
|
expect(questionProvider.config.method).toBe('POST');
|
|
expect(questionProvider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/ask',
|
|
);
|
|
expect(questionProvider.config.body).toEqual({
|
|
tenant_id: '{{tenant_id}}',
|
|
question: '{{prompt}}',
|
|
});
|
|
expect(questionProvider.config.transformResponse).toBe('json.answer');
|
|
const questionTest = (generatedQuestion.tests as unknown[])[0];
|
|
expectRecord(questionTest, 'Generated OpenAPI question test');
|
|
expect(questionTest.vars).toEqual({
|
|
tenant_id: 'sample-tenant-id',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
|
|
const createdOutput = execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'createInvoiceNote',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_TOKEN',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
const generatedCreated = yaml.load(createdOutput);
|
|
expectRecord(generatedCreated, 'Generated OpenAPI 201 config');
|
|
const createdProvider = (generatedCreated.providers as unknown[])[0];
|
|
expectRecord(createdProvider, 'Generated OpenAPI 201 provider');
|
|
expectRecord(createdProvider.config, 'Generated OpenAPI 201 provider config');
|
|
expect(createdProvider.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/{{invoice_id | urlencode}}/notes',
|
|
);
|
|
expect(createdProvider.config.method).toBe('POST');
|
|
expect(createdProvider.config.queryParams).toEqual({
|
|
tenant_id: '{{tenant_id}}',
|
|
});
|
|
expect(createdProvider.config.body).toEqual({ user_id: '{{user_id}}', message: '{{prompt}}' });
|
|
expect(createdProvider.config.transformResponse).toBe('json.output');
|
|
const createdTest = (generatedCreated.tests as unknown[])[0];
|
|
expectRecord(createdTest, 'Generated OpenAPI 201 test');
|
|
expect(createdTest.vars).toEqual({
|
|
invoice_id: 'sample-invoice-id',
|
|
tenant_id: 'sample-tenant-id',
|
|
user_id: 'sample-user-id',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
});
|
|
|
|
it('fails clearly for unsupported external OpenAPI refs', () => {
|
|
expectOpenApiHelperFailure(
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
{
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/broken': {
|
|
get: {
|
|
operationId: 'brokenOperation',
|
|
responses: {
|
|
'200': {
|
|
description: 'Broken response',
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: 'https://example.test/schemas/Response.yaml' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'Only local OpenAPI refs are supported',
|
|
);
|
|
});
|
|
|
|
it('keeps a deterministic smoke message var for OpenAPI operations without prompt inputs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/health': {
|
|
get: {
|
|
operationId: 'getHealth',
|
|
responses: {
|
|
'200': {
|
|
description: 'Health response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
status: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'getHealth',
|
|
'--base-url-env',
|
|
'HEALTH_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated no-prompt OpenAPI provider config');
|
|
expect(generated.prompts).toEqual(['{{message}}']);
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated no-prompt OpenAPI provider');
|
|
expectRecord(provider.config, 'Generated no-prompt OpenAPI provider config block');
|
|
expect(provider.config.url).toBe('{{env.HEALTH_API_BASE_URL}}/health');
|
|
expect(provider.config.queryParams).toBeUndefined();
|
|
expect(provider.config.body).toBeUndefined();
|
|
expect(provider.config.transformResponse).toBe('json.status');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated no-prompt OpenAPI provider test');
|
|
expect(test.vars).toEqual({
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
// The "Say exactly PONG." message never reaches the target here, so the
|
|
// smoke assertion falls back to is-json on the structured response
|
|
// instead of `contains: PONG` (which would fail by construction).
|
|
expect(test.assert).toEqual([{ type: 'is-json' }]);
|
|
expect(test.description).toBe('getHealth responds successfully');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('prefers OpenAPI parameter and media examples for provider smoke vars', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExamplePrecedenceSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createExampleNote',
|
|
'--base-url-env',
|
|
'EXAMPLE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example precedence provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated example precedence provider');
|
|
expectRecord(provider.config, 'Generated example precedence provider config block');
|
|
expect(provider.config.url).toBe(
|
|
'{{env.EXAMPLE_API_BASE_URL}}/example/{{invoice_id | urlencode}}',
|
|
);
|
|
expect(provider.config.queryParams).toEqual({
|
|
'tenant-id': '{{tenant_id}}',
|
|
});
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
note_type: '{{note_type}}',
|
|
});
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated example precedence provider test');
|
|
expect(test.vars).toEqual({
|
|
invoice_id: 'invoice-from-parameter-example',
|
|
tenant_id: 'tenant-from-example-ref',
|
|
user_id: 'user-from-media-example',
|
|
note_type: 'note-type-from-media-example',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses vendor +json OpenAPI media types for provider request and response schemas', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiVendorJsonSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createVendorJsonNote',
|
|
'--base-url-env',
|
|
'VENDOR_JSON_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated vendor JSON provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated vendor JSON provider');
|
|
expectRecord(provider.config, 'Generated vendor JSON provider config block');
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': 'application/vnd.promptfoo.note+json',
|
|
});
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.answer');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated vendor JSON provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'vendor-json-user',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI form-urlencoded request bodies for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiFormUrlEncodedSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'formChat',
|
|
'--base-url-env',
|
|
'FORM_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated form provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated form provider');
|
|
expectRecord(provider.config, 'Generated form provider config block');
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
});
|
|
expect(provider.config.body).toBe(
|
|
'user_id={{user_id | urlencode}}&message={{prompt | urlencode}}&scope={{scope | urlencode}}',
|
|
);
|
|
expect(provider.config.transformResponse).toBe('json.answer');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated form provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'form-user',
|
|
scope: 'billing support',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI multipart request bodies for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiMultipartSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'analyzeFile',
|
|
'--base-url-env',
|
|
'MULTIPART_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated multipart provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated multipart provider');
|
|
expectRecord(provider.config, 'Generated multipart provider config block');
|
|
expect(provider.config.headers).toBeUndefined();
|
|
expect(provider.config.body).toBeUndefined();
|
|
expect(provider.config.multipart).toEqual({
|
|
parts: [
|
|
{
|
|
kind: 'file',
|
|
name: 'document',
|
|
filename: 'promptfoo-document.pdf',
|
|
source: {
|
|
type: 'generated',
|
|
generator: 'basic-document',
|
|
format: 'pdf',
|
|
text: 'Promptfoo generated document for {{document}}.',
|
|
},
|
|
},
|
|
{ kind: 'field', name: 'question', value: '{{prompt}}' },
|
|
{ kind: 'field', name: 'user_id', value: '{{user_id}}' },
|
|
],
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.output');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated multipart provider test');
|
|
expect(test.vars).toEqual({
|
|
document: 'sample-document',
|
|
user_id: 'multipart-user',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI text request bodies for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiTextPlainSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'textChat',
|
|
'--base-url-env',
|
|
'TEXT_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated text provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated text provider');
|
|
expectRecord(provider.config, 'Generated text provider config block');
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': 'text/plain',
|
|
});
|
|
expect(provider.config.body).toBe('{{prompt}}');
|
|
expect(provider.config.transformResponse).toBe('json.output');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated text provider test');
|
|
expect(test.vars).toEqual({
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves scalar OpenAPI request examples without schemas for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiScalarExampleRequestSpec()));
|
|
for (const [operationId, contentType] of [
|
|
['jsonScalarChat', 'application/json'],
|
|
['jsonScalarSchemaChat', 'application/json'],
|
|
['textScalarChat', 'text/plain'],
|
|
]) {
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'SCALAR_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, `Generated ${operationId} provider config`);
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, `Generated ${operationId} provider`);
|
|
expectRecord(provider.config, `Generated ${operationId} provider config block`);
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': contentType,
|
|
});
|
|
expect(provider.config.body).toBe('{{prompt}}');
|
|
expect(provider.config.transformResponse).toBe('json.output');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, `Generated ${operationId} provider test`);
|
|
expect(test.vars).toEqual({
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
}
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI root array request bodies for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiArrayRequestSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'batchChat',
|
|
'--base-url-env',
|
|
'BATCH_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated array provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated array provider');
|
|
expectRecord(provider.config, 'Generated array provider config block');
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
});
|
|
expect(provider.config.body).toEqual([
|
|
{
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
priority: '{{priority}}',
|
|
},
|
|
]);
|
|
expect(provider.config.transformResponse).toBe('json.output');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated array provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'array-user',
|
|
priority: 'normal',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves OpenAPI example-only array request bodies for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExampleOnlyArrayBodySpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'exampleArrayChat',
|
|
'--base-url-env',
|
|
'EXAMPLE_ARRAY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example-only array provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated example-only array provider');
|
|
expectRecord(provider.config, 'Generated example-only array provider config block');
|
|
expect(provider.config.body).toEqual([
|
|
{
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
priority: '{{priority}}',
|
|
},
|
|
]);
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated example-only array provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'example-array-user',
|
|
priority: 'expedite',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('extracts first-item fields from OpenAPI array responses for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiArrayResponseSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'arrayResponseChat',
|
|
'--base-url-env',
|
|
'ARRAY_RESPONSE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated array-response provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated array-response provider');
|
|
expectRecord(provider.config, 'Generated array-response provider config block');
|
|
expect(provider.config.body).toEqual({
|
|
message: '{{prompt}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json[0].output');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated array-response provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'array-response-user',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('extracts first-item fields from OpenAPI example-only array responses for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExampleOnlyArrayResponseSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'exampleArrayResponseChat',
|
|
'--base-url-env',
|
|
'EXAMPLE_ARRAY_RESPONSE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example-only array-response provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated example-only array-response provider');
|
|
expectRecord(provider.config, 'Generated example-only array-response provider config block');
|
|
expect(provider.config.transformResponse).toBe('json[0].output');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated example-only array-response provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'example-array-response-user',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('quotes unsafe OpenAPI response field accessors for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiUnsafeResponseFieldSpec()));
|
|
const runHelper = (operationId: string) =>
|
|
yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'UNSAFE_RESPONSE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
|
|
const objectGenerated = runHelper('unsafeResponseField');
|
|
expectRecord(objectGenerated, 'Generated unsafe response field provider config');
|
|
const [objectProvider] = objectGenerated.providers as unknown[];
|
|
expectRecord(objectProvider, 'Generated unsafe response field provider');
|
|
expectRecord(objectProvider.config, 'Generated unsafe response field provider config block');
|
|
expect(objectProvider.config.transformResponse).toBe('json["api-version"]');
|
|
|
|
const arrayGenerated = runHelper('unsafeArrayResponseField');
|
|
expectRecord(arrayGenerated, 'Generated unsafe array response field provider config');
|
|
const [arrayProvider] = arrayGenerated.providers as unknown[];
|
|
expectRecord(arrayProvider, 'Generated unsafe array response field provider');
|
|
expectRecord(arrayProvider.config, 'Generated unsafe array response field provider config');
|
|
expect(arrayProvider.config.transformResponse).toBe('json[0]["200"]');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('merges OpenAPI allOf request and response schemas for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiAllOfSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createAllOfNote',
|
|
'--base-url-env',
|
|
'ALLOF_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated allOf provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated allOf provider');
|
|
expectRecord(provider.config, 'Generated allOf provider config block');
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
note_type: '{{note_type}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.answer');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated allOf provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'allof-user',
|
|
note_type: 'internal-note',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows repeated non-cyclic OpenAPI refs for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiRepeatedRefSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createRepeatedRefNote',
|
|
'--base-url-env',
|
|
'REPEATED_REF_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated repeated-ref provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated repeated-ref provider');
|
|
expectRecord(provider.config, 'Generated repeated-ref provider config block');
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.answer');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated repeated-ref provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'repeat-user',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('normalizes camelCase OpenAPI identifiers for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiCamelCaseIdSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'chatWithCamelCaseInvoice',
|
|
'--base-url-env',
|
|
'CAMEL_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated camelCase provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated camelCase provider');
|
|
expectRecord(provider.config, 'Generated camelCase provider config block');
|
|
expect(provider.config.url).toBe(
|
|
'{{env.CAMEL_API_BASE_URL}}/users/{{user_id | urlencode}}/invoices/{{invoice_id | urlencode}}/chat',
|
|
);
|
|
expect(provider.config.queryParams).toEqual({
|
|
apiVersion: '{{api_version}}',
|
|
});
|
|
expect(provider.config.body).toEqual({
|
|
accountId: '{{account_id}}',
|
|
message: '{{prompt}}',
|
|
});
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated camelCase provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'camel-user',
|
|
invoice_id: 'camel-invoice',
|
|
api_version: '2026-04-17',
|
|
account_id: 'camel-account',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses the first OpenAPI oneOf/anyOf variant for provider config drafts', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiVariantSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'variantSearch',
|
|
'--base-url-env',
|
|
'VARIANT_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated variant provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated variant provider');
|
|
expectRecord(provider.config, 'Generated variant provider config block');
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
query: '{{prompt}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.answer');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated variant provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'variant-user',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI request body examples when provider schemas are omitted', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExampleOnlyBodySpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createExampleOnlyNote',
|
|
'--base-url-env',
|
|
'EXAMPLE_ONLY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example-only provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated example-only provider');
|
|
expectRecord(provider.config, 'Generated example-only provider config block');
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
});
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
note_type: '{{note_type}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.answer');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated example-only provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'example-user',
|
|
note_type: 'support-note',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI schema types for provider smoke vars', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiTypedSchemaSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createTypedNote',
|
|
'--base-url-env',
|
|
'TYPED_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated typed provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated typed provider');
|
|
expectRecord(provider.config, 'Generated typed provider config block');
|
|
expect(provider.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{prompt}}',
|
|
limit: '{{limit}}',
|
|
include_archived: '{{include_archived}}',
|
|
tags: '{{tags}}',
|
|
metadata: '{{metadata}}',
|
|
status: '{{status}}',
|
|
contact_email: '{{contact_email}}',
|
|
callback_url: '{{callback_url}}',
|
|
request_id: '{{request_id}}',
|
|
scheduled_date: '{{scheduled_date}}',
|
|
created_at: '{{created_at}}',
|
|
confidence: '{{confidence}}',
|
|
});
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated typed provider test');
|
|
expect(test.vars).toEqual({
|
|
user_id: 'typed-user',
|
|
limit: 1,
|
|
include_archived: true,
|
|
tags: ['finance'],
|
|
metadata: {
|
|
region: 'us-west',
|
|
priority: 1,
|
|
},
|
|
status: 'queued',
|
|
contact_email: 'user@example.com',
|
|
callback_url: 'https://example.test/resource',
|
|
request_id: '00000000-0000-4000-8000-000000000000',
|
|
scheduled_date: '2026-04-17',
|
|
created_at: '2026-04-17T00:00:00Z',
|
|
confidence: 1,
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('lets OpenAPI operation parameters override path-item parameters for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiParameterOverrideSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'overrideParameters',
|
|
'--base-url-env',
|
|
'OVERRIDE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated override provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated override provider');
|
|
expectRecord(provider.config, 'Generated override provider config block');
|
|
expect(provider.config.url).toBe(
|
|
'{{env.OVERRIDE_API_BASE_URL}}/override/{{resource_id | urlencode}}',
|
|
);
|
|
expect(provider.config.queryParams).toEqual({
|
|
'api-version': '{{api_version}}',
|
|
});
|
|
expect(provider.config.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
'X-Trace-Id': '{{x_trace_id}}',
|
|
});
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated override provider test');
|
|
expect(test.vars).toEqual({
|
|
resource_id: 'resource-123',
|
|
api_version: 'operation-version',
|
|
x_trace_id: 'operation-trace',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips OpenAPI writeOnly response fields for provider response transforms', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiWriteOnlyResponseSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'writeOnlyResponse',
|
|
'--base-url-env',
|
|
'WRITE_ONLY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated writeOnly response provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated writeOnly response provider');
|
|
expectRecord(provider.config, 'Generated writeOnly response provider config block');
|
|
expect(provider.config.transformResponse).toBe('json.public_text');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('honors composed OpenAPI visibility flags for provider configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-provider-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiComposedVisibilitySpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'composedVisibility',
|
|
'--base-url-env',
|
|
'COMPOSED_VISIBILITY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated composed visibility provider config');
|
|
const [provider] = generated.providers as unknown[];
|
|
expectRecord(provider, 'Generated composed visibility provider');
|
|
expectRecord(provider.config, 'Generated composed visibility provider config block');
|
|
expect(provider.config.body).toEqual({
|
|
message: '{{prompt}}',
|
|
client_note: '{{client_note}}',
|
|
});
|
|
expect(provider.config.transformResponse).toBe('json.public_text');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated composed visibility provider test');
|
|
expect(test.vars).toEqual({
|
|
client_note: 'sample-client_note',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails clearly for circular OpenAPI refs', () => {
|
|
expectOpenApiHelperFailure(
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
{
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/broken': {
|
|
post: {
|
|
operationId: 'brokenOperation',
|
|
requestBody: {
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/A' },
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Broken response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
components: {
|
|
schemas: {
|
|
A: { $ref: '#/components/schemas/B' },
|
|
B: { $ref: '#/components/schemas/A' },
|
|
},
|
|
},
|
|
},
|
|
'Circular OpenAPI ref detected',
|
|
);
|
|
});
|
|
|
|
it('treats OpenAPI example: null as missing so schema-typed samples fall through', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-null-example-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/null-example-ask': {
|
|
post: {
|
|
operationId: 'nullExampleAsk',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['tenant_id', 'message'],
|
|
properties: {
|
|
tenant_id: { type: 'string', example: null },
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Null example response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'nullExampleAsk',
|
|
'--base-url-env',
|
|
'NULL_EXAMPLE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated null-example provider config');
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated null-example provider test');
|
|
expect(test.vars).toEqual({
|
|
tenant_id: 'sample-tenant-id',
|
|
message: 'Say exactly PONG.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps --auth-prefix from corrupting conjunctive API-key auths', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-auth-prefix-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiConjunctiveAuthSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'conjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
'--auth-prefix',
|
|
'Token',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated auth-prefix override config');
|
|
const provider = (generated.providers as unknown[])[0];
|
|
expectRecord(provider, 'Generated auth-prefix override provider');
|
|
expectRecord(provider.config, 'Generated auth-prefix override provider config');
|
|
// Bearer prefix is overridden; the X-API-Key and Cookie auths (prefix 'none') are untouched.
|
|
expect(provider.config.headers).toEqual({
|
|
Authorization: 'Token {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
'X-API-Key': '{{env.CONJUNCTIVE_API_TOKEN}}',
|
|
Cookie:
|
|
'session_id={{env.CONJUNCTIVE_API_TOKEN}}; csrf_token={{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('forces env placeholders for credential-like header, query, and cookie params', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-credential-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/search': {
|
|
get: {
|
|
operationId: 'leakyAuthSearch',
|
|
parameters: [
|
|
{
|
|
name: 'Authorization',
|
|
in: 'header',
|
|
schema: { type: 'string', example: 'Bearer SECRET_TOKEN_123' },
|
|
},
|
|
{
|
|
name: 'api_key',
|
|
in: 'query',
|
|
schema: { type: 'string', example: 'PROD_API_KEY_456' },
|
|
},
|
|
{
|
|
name: 'session_id',
|
|
in: 'cookie',
|
|
schema: { type: 'string', example: 'sess_XYZ' },
|
|
},
|
|
{ name: 'q', in: 'query', schema: { type: 'string' } },
|
|
],
|
|
responses: {
|
|
'200': {
|
|
description: 'ok',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(providerSkillRoot, 'scripts', 'openapi-operation-to-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'leakyAuthSearch',
|
|
'--base-url-env',
|
|
'LEAKY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated credential-leak provider config');
|
|
const provider = (generated.providers as unknown[])[0];
|
|
expectRecord(provider, 'Generated credential-leak provider');
|
|
expectRecord(provider.config, 'Generated credential-leak provider config block');
|
|
// Credential-like header/query/cookie params route through env placeholders,
|
|
// NOT through vars (which would embed the raw example token into the config).
|
|
expect(provider.config.headers).toEqual({
|
|
Authorization: '{{env.AUTHORIZATION}}',
|
|
Cookie: 'session_id={{env.SESSION_ID}}',
|
|
});
|
|
expect(provider.config.queryParams).toEqual({
|
|
api_key: '{{env.API_KEY}}',
|
|
q: '{{prompt}}',
|
|
});
|
|
const [test] = generated.tests as unknown[];
|
|
expectRecord(test, 'Generated credential-leak provider test');
|
|
expect(test.vars).toEqual({ message: 'Say exactly PONG.' });
|
|
// No literal secrets copied from the OpenAPI example values.
|
|
const serialized = yaml.dump(generated);
|
|
expect(serialized).not.toContain('SECRET_TOKEN_123');
|
|
expect(serialized).not.toContain('PROD_API_KEY_456');
|
|
expect(serialized).not.toContain('sess_XYZ');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('promptfoo-redteam-setup skill', () => {
|
|
it('has a routing description with setup scope and run/provider boundaries', () => {
|
|
const skill = readText(path.join(redteamSetupSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toMatch(/^---\nname: promptfoo-redteam-setup\n/);
|
|
expect(skill).toContain('purpose');
|
|
expect(skill).toContain('plugins');
|
|
expect(skill).toContain('strategies');
|
|
expect(skill).toContain('multi-input target inputs');
|
|
expect(skill).toContain('Do not use for');
|
|
expect(skill).toContain('basic provider wiring');
|
|
expect(skill).toContain('running/evaluating an already-generated');
|
|
});
|
|
|
|
it('documents focused redteam setup workflow and generation QA', () => {
|
|
const skill = readText(path.join(redteamSetupSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toContain('Derive target facts from live or static evidence');
|
|
expect(skill).toContain('Write the target and purpose');
|
|
expect(skill).toContain('Choose a small plugin set');
|
|
expect(skill).toContain('Choose strategies conservatively');
|
|
expect(skill).toContain('Use `jailbreak:meta` for the default first setup/generation pass.');
|
|
expect(skill).toContain('Use `jailbreak:hydra` instead when the target is stateful');
|
|
expect(skill).toContain('search route handlers, API clients, tests');
|
|
expect(skill).toContain('object IDs imply `bola`');
|
|
expect(skill).toContain("Promptfoo's default redteam generation");
|
|
expect(skill).toContain('redteam.provider');
|
|
expect(skill).toContain('file://x.py:name');
|
|
expect(skill).toContain('openapi-operation-to-redteam-config.mjs');
|
|
expect(skill).toContain('--auth-header');
|
|
expect(skill).toContain('--auth-prefix');
|
|
expect(skill).toContain('infers Bearer/OAuth2/OpenID and header/query/cookie API-key auth');
|
|
expect(skill).toContain('--smoke-test true');
|
|
expect(skill).toContain('validate target');
|
|
expect(skill).toContain('redteam generate');
|
|
expect(skill).toContain('metadata.pluginId');
|
|
expect(skill).toContain('defaultTest.metadata.purpose');
|
|
expect(skill).toContain('non-precreated output path');
|
|
expect(skill).toContain('config-relative `file://./target.js`');
|
|
expect(skill).toContain('file targets resolve under `/tmp`');
|
|
});
|
|
|
|
it('assumes remote redteam generation is available', () => {
|
|
const redteamMarkdown = [
|
|
readText(path.join(redteamSetupSkillRoot, 'SKILL.md')),
|
|
readText(path.join(redteamSetupSkillRoot, 'references', 'redteam-setup-patterns.md')),
|
|
readText(path.join(redteamRunSkillRoot, 'SKILL.md')),
|
|
readText(path.join(redteamRunSkillRoot, 'references', 'redteam-run-patterns.md')),
|
|
].join('\n');
|
|
|
|
expect(redteamMarkdown).toContain("Promptfoo's default redteam generation");
|
|
for (const forbidden of [
|
|
['PROMPTFOO_DISABLE', 'REMOTE_GENERATION'].join('_'),
|
|
['local-only', 'generation'].join(' '),
|
|
['Local', 'Only', 'QA'].join('-').replace('-QA', ' QA'),
|
|
['remote', 'only'].join('-'),
|
|
['data must', 'stay local'].join(' '),
|
|
['deferred', 'remote', 'enabled scan'].join(' ').replace('remote ', 'remote-'),
|
|
]) {
|
|
expect(redteamMarkdown).not.toContain(forbidden);
|
|
}
|
|
});
|
|
|
|
it('ships Codex UI metadata for redteam setup', () => {
|
|
const openaiYaml = readText(path.join(redteamSetupSkillRoot, 'agents', 'openai.yaml'));
|
|
|
|
expect(openaiYaml).toContain('Promptfoo Redteam Setup');
|
|
expect(openaiYaml).toContain('$promptfoo-redteam-setup');
|
|
expect(openaiYaml).toContain('allow_implicit_invocation: true');
|
|
});
|
|
|
|
it('keeps redteam examples in a progressive-disclosure reference file', () => {
|
|
const reference = readText(
|
|
path.join(redteamSetupSkillRoot, 'references', 'redteam-setup-patterns.md'),
|
|
);
|
|
|
|
expect(reference).toContain('Single-input HTTP policy scan');
|
|
expect(reference).toContain('Multi-input authorization scan');
|
|
expect(reference).toContain('Multi-input is not the same as multi-turn.');
|
|
expect(reference).toContain('jailbreak:meta');
|
|
expect(reference).toContain('jailbreak:hydra');
|
|
expect(reference).toContain('provider: file://./redteam-generator.mjs');
|
|
expect(reference).toContain('file://./redteam-generator.py');
|
|
expect(reference).toContain('call_api(prompt, options, context)');
|
|
expect(reference).toContain("json.dumps(payload, separators=(',', ':'))");
|
|
expect(reference).toContain('Relative to the command working directory');
|
|
expect(reference).toContain('repo-root-relative path');
|
|
expect(reference).toContain('Before generating against a live target');
|
|
expect(reference).toContain('inspect the observed request/response');
|
|
expect(reference).toContain('id: policy');
|
|
expect(reference).toContain('Add `bola` or `bfla`');
|
|
expect(reference).toContain('id: rbac');
|
|
expect(reference).toContain('Generation QA commands');
|
|
expect(reference).toContain('multi-input-mode');
|
|
expect(reference).toContain('empty `mktemp` file');
|
|
expect(reference).toContain('metadata.configHash');
|
|
expect(reference).toContain('defaultTest?.metadata?.purpose');
|
|
expect(reference).toContain('For local file targets such as `file://./target.js`');
|
|
expect(reference).toContain('file://provider.py:invoice_redteam_target');
|
|
expect(reference).toContain('file://provider.py:function_name');
|
|
expect(reference).toContain('file://generator.py:function_name');
|
|
expect(reference).toContain('file://redteam-generator.py:generate_redteam_invoice_prompt');
|
|
expect(reference).toContain('timeout: 30000');
|
|
expect(reference).toContain('anchor `sys.path`');
|
|
expect(reference).toContain('Path(__file__).resolve().parent');
|
|
expect(reference).toContain('constructor `options.config`');
|
|
expect(reference).toContain('options` argument to the selected function');
|
|
expect(reference).toContain('resolve under `/tmp`');
|
|
expect(reference).toContain('OpenAPI operation to redteam setup');
|
|
expect(reference).toContain('openapi-operation-to-redteam-config.mjs');
|
|
expect(reference).toContain('`defaultTest.vars`');
|
|
expect(reference).toContain('`allOf`');
|
|
expect(reference).toContain('`oneOf`/`anyOf`');
|
|
expect(reference).toContain('parameter/media examples');
|
|
expect(reference).toContain('+json media');
|
|
expect(reference).toContain('defaults/enums');
|
|
expect(reference).toContain('safe target `inputs`');
|
|
expect(reference).toContain('header/query fields to `headers`/`queryParams`');
|
|
expect(reference).toContain('Use `--policy`');
|
|
expect(reference).toContain('`--num-tests`');
|
|
expect(reference).toContain('Bearer/OAuth2/OpenID/header/query/cookie');
|
|
expect(reference).toContain('--auth-header X-API-Key --auth-prefix');
|
|
expect(reference).toContain('--smoke-test true');
|
|
expect(reference).toContain('--smoke-assert');
|
|
expect(reference).toContain('empty connectivity vars');
|
|
expect(reference).toContain('`policy` plus `rbac`');
|
|
expect(reference).toContain('Static code to redteam setup');
|
|
expect(reference).toContain('Route evidence: file path, method, path');
|
|
expect(reference).toContain('POST /api/invoices/:invoice_id/chat');
|
|
expect(reference).toContain('Authorization');
|
|
expect(reference).toContain('id: bola');
|
|
expect(reference).toContain('Use it directly');
|
|
expect(reference).toContain('target has identity or object fields to attack');
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
dir: 'redteam-setup-single-input',
|
|
snippets: [
|
|
'prompts:',
|
|
'targets:',
|
|
'redteam:',
|
|
'redteam-generator.mjs',
|
|
'id: policy',
|
|
'frameworks:',
|
|
'strategies:',
|
|
'jailbreak:meta',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-setup-multi-input',
|
|
snippets: [
|
|
'targets:',
|
|
'inputs:',
|
|
'user_id:',
|
|
'trip_id:',
|
|
'redteam-generator.mjs',
|
|
'id: rbac',
|
|
'jailbreak:meta',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-setup-live-http',
|
|
snippets: [
|
|
'targets:',
|
|
'inputs:',
|
|
"url: '{{env.REDTEAM_SETUP_LIVE_HTTP_URL}}'",
|
|
'Authorization:',
|
|
'Bearer {{env.REDTEAM_SETUP_LIVE_HTTP_TOKEN}}',
|
|
'redteam-generator.mjs',
|
|
'id: policy',
|
|
'id: rbac',
|
|
'jailbreak:meta',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-setup-static-code',
|
|
snippets: [
|
|
'file://./target.mjs',
|
|
'defaultUserId: qa-static-config',
|
|
'inputs:',
|
|
'invoice_id:',
|
|
'redteam-generator.mjs',
|
|
'id: policy',
|
|
'id: rbac',
|
|
'jailbreak:meta',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-setup-static-code-python',
|
|
snippets: [
|
|
'file://./target.py:invoice_redteam_target',
|
|
'workers: 1',
|
|
'timeout: 30000',
|
|
'defaultUserId: qa-py-static-config',
|
|
'inputs:',
|
|
'invoice_id:',
|
|
'redteam-generator.py',
|
|
'id: policy',
|
|
'id: rbac',
|
|
'jailbreak:meta',
|
|
],
|
|
},
|
|
])('ships a validatable $dir redteam setup fixture', ({ dir, snippets }) => {
|
|
const config = readText(path.join(fixtureRoot, dir, 'promptfooconfig.yaml'));
|
|
const generatorPath =
|
|
['redteam-generator.mjs', 'redteam-generator.py']
|
|
.map((fileName) => path.join(fixtureRoot, dir, fileName))
|
|
.find((filePath) => fs.existsSync(filePath)) ?? '';
|
|
const generator = readText(generatorPath);
|
|
|
|
expect(config).toContain(
|
|
'# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json',
|
|
);
|
|
expect(config).toContain('description: Redteam setup');
|
|
if (generatorPath.endsWith('.mjs')) {
|
|
expect(generator).toContain('export default class');
|
|
expect(generator).toContain('async callApi');
|
|
} else if (dir === 'redteam-setup-static-code-python') {
|
|
expect(generator).toContain(
|
|
'def generate_redteam_invoice_prompt(prompt: str, options: dict, context: dict)',
|
|
);
|
|
} else {
|
|
expect(generator).toContain('def call_api(prompt: str, options: dict, context: dict)');
|
|
}
|
|
expect(generator).toContain('Prompt');
|
|
|
|
for (const snippet of snippets) {
|
|
expect(config).toContain(snippet);
|
|
}
|
|
});
|
|
|
|
it('ships a static-code-derived redteam setup fixture with route evidence', () => {
|
|
const config = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code', 'promptfooconfig.yaml'),
|
|
);
|
|
const target = readText(path.join(fixtureRoot, 'redteam-setup-static-code', 'target.mjs'));
|
|
const app = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code', 'app', 'invoiceAgent.mjs'),
|
|
);
|
|
const routes = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code', 'app', 'routes.mjs'),
|
|
);
|
|
|
|
expect(config).toContain('description: Redteam setup static-code-derived invoice smoke');
|
|
expect(config).toContain('label: redteam-setup-static-invoice-target');
|
|
expect(config).toContain('defaultUserId: qa-static-config');
|
|
expect(config).toContain('user_id: Signed-in user identifier');
|
|
expect(config).toContain('invoice_id: Invoice object being requested');
|
|
expect(config).toContain('The assistant must not disclose or modify invoices');
|
|
expect(config).not.toContain('id: bola');
|
|
expect(target).toContain("import { invoiceAgent } from './app/invoiceAgent.mjs'");
|
|
expect(target).toContain("import { invoiceChatRoute } from './app/routes.mjs'");
|
|
expect(target).toContain('invoiceChatRoute.safeDefaults.userId');
|
|
expect(target).toContain('route: invoiceChatRoute.path');
|
|
expect(app).toContain('invoiceOwners');
|
|
expect(app).toContain('DENIED invoice');
|
|
expect(routes).toContain("path: '/api/invoices/:invoice_id/chat'");
|
|
expect(routes).toContain("authHeader: 'Authorization'");
|
|
expect(routes).toContain("ownershipCheck: 'invoice.owner_user_id === body.user_id'");
|
|
expect(routes).toContain("plugins: ['policy', 'rbac', 'bola']");
|
|
});
|
|
|
|
it('ships a static-code-derived Python redteam setup fixture with route evidence', () => {
|
|
const config = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code-python', 'promptfooconfig.yaml'),
|
|
);
|
|
const target = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code-python', 'target.py'),
|
|
);
|
|
const app = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code-python', 'app', 'invoice_agent.py'),
|
|
);
|
|
const routes = readText(
|
|
path.join(fixtureRoot, 'redteam-setup-static-code-python', 'app', 'routes.py'),
|
|
);
|
|
|
|
expect(config).toContain('description: Redteam setup static-code-derived Python invoice smoke');
|
|
expect(config).toContain('label: redteam-setup-static-python-invoice-target');
|
|
expect(config).toContain('file://./target.py:invoice_redteam_target');
|
|
expect(config).toContain('redteam-generator.py:generate_redteam_invoice_prompt');
|
|
expect(config).toContain('workers: 1');
|
|
expect(config).toContain('timeout: 30000');
|
|
expect(config).toContain('defaultUserId: qa-py-static-config');
|
|
expect(config).toContain('user_id: Signed-in user identifier');
|
|
expect(config).toContain('invoice_id: Invoice object being requested');
|
|
expect(config).toContain('The assistant must not disclose or modify invoices');
|
|
expect(config).not.toContain('id: bola');
|
|
expect(target).toContain('sys.path.insert(0, str(Path(__file__).resolve().parent))');
|
|
expect(target).toContain('from app.invoice_agent import invoice_agent');
|
|
expect(target).toContain('from app.routes import INVOICE_CHAT_ROUTE');
|
|
expect(target).toContain(
|
|
'def invoice_redteam_target(prompt: str, options: dict, context: dict) -> dict:',
|
|
);
|
|
expect(target).toContain('INVOICE_CHAT_ROUTE["safe_defaults"]');
|
|
expect(target).toContain('"route": INVOICE_CHAT_ROUTE["path"]');
|
|
expect(app).toContain('INVOICE_OWNERS');
|
|
expect(app).toContain('DENIED invoice');
|
|
expect(routes).toContain('"/api/invoices/{invoice_id}/chat"');
|
|
expect(routes).toContain('"auth_header": "Authorization"');
|
|
expect(routes).toContain('"ownership_check": "invoice.owner_user_id == body.user_id"');
|
|
expect(routes).toContain('"plugins": ["policy", "rbac", "bola"]');
|
|
});
|
|
|
|
it('ships a live-HTTP redteam setup fixture with auth and multi-input mapping', () => {
|
|
const config = yaml.load(
|
|
readText(path.join(fixtureRoot, 'redteam-setup-live-http', 'promptfooconfig.yaml')),
|
|
);
|
|
expectRecord(config, 'live HTTP redteam setup config');
|
|
|
|
const [target] = config.targets as unknown[];
|
|
expectRecord(target, 'live HTTP redteam target');
|
|
expect(target.id).toBe('https');
|
|
expect(target.label).toBe('redteam-setup-live-http-trip-agent');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Signed-in user identifier.',
|
|
trip_id: 'Trip identifier being requested.',
|
|
message: 'User message to the assistant.',
|
|
});
|
|
expectRecord(target.config, 'live HTTP target config');
|
|
expect(target.config.url).toBe('{{env.REDTEAM_SETUP_LIVE_HTTP_URL}}');
|
|
expect(target.config.method).toBe('POST');
|
|
expect(target.config.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: 'Bearer {{env.REDTEAM_SETUP_LIVE_HTTP_TOKEN}}',
|
|
}),
|
|
);
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
trip_id: '{{trip_id}}',
|
|
message: '{{message}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.output');
|
|
|
|
expectRecord(config.redteam, 'live HTTP redteam block');
|
|
expect(config.redteam.provider).toBe(
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/redteam-generator.mjs',
|
|
);
|
|
expect(config.redteam.plugins).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: 'policy' }),
|
|
{ id: 'rbac', numTests: 1 },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('ships an OpenAPI helper script that drafts a redteam setup config', () => {
|
|
const script = readText(
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
);
|
|
expect(script).toContain('--generator-provider file://redteam-generator.mjs');
|
|
expect(script).not.toContain('--generator-provider file://grader.mjs');
|
|
|
|
const runHelper = (operationId: string, extraArgs: string[] = []) =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--token-env',
|
|
'OPENAPI_INVOICE_API_TOKEN',
|
|
'--generator-provider',
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
...extraArgs,
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
);
|
|
|
|
const output = runHelper('createInvoiceNote', ['--label', 'generated-openapi-redteam-note']);
|
|
const generated = yaml.load(output);
|
|
expect(output).toContain(
|
|
'# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json',
|
|
);
|
|
expectRecord(generated, 'Generated OpenAPI redteam config');
|
|
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated OpenAPI redteam target');
|
|
expect(target.id).toBe('https');
|
|
expect(target.label).toBe('generated-openapi-redteam-note');
|
|
expect(target.inputs).toEqual({
|
|
invoice_id: 'Object identifier being requested: invoice_id.',
|
|
tenant_id: 'Caller identity or tenancy field: tenant_id.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expectRecord(target.config, 'Generated OpenAPI redteam target config');
|
|
expect(target.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/{{invoice_id | urlencode}}/notes',
|
|
);
|
|
expect(target.config.method).toBe('POST');
|
|
expect(target.config.headers).toEqual(
|
|
expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
Authorization: 'Bearer {{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
}),
|
|
);
|
|
expect(target.config.queryParams).toEqual({ tenant_id: '{{tenant_id}}' });
|
|
expect(target.config.body).toEqual({ user_id: '{{user_id}}', message: '{{message}}' });
|
|
expect(target.config.transformResponse).toBe('json.output');
|
|
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
invoice_id: 'sample-invoice-id',
|
|
tenant_id: 'sample-tenant-id',
|
|
user_id: 'sample-user-id',
|
|
message: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
|
|
expectRecord(generated.redteam, 'Generated OpenAPI redteam block');
|
|
expect(generated.redteam.provider).toBe(
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
);
|
|
expect(generated.redteam.purpose).toContain('createInvoiceNote');
|
|
expect(generated.redteam.plugins).toEqual([
|
|
expect.objectContaining({ id: 'policy', numTests: 1 }),
|
|
{ id: 'rbac', numTests: 1 },
|
|
{ id: 'bola', numTests: 1 },
|
|
]);
|
|
expect(generated.redteam.strategies).toEqual(['jailbreak:meta']);
|
|
const [policyPlugin] = generated.redteam.plugins as unknown[];
|
|
expectRecord(policyPlugin, 'Generated OpenAPI redteam policy plugin');
|
|
expectRecord(policyPlugin.config, 'Generated OpenAPI redteam policy config');
|
|
expect(policyPlugin.config.policy).toContain('invoice_id');
|
|
|
|
const searchGenerated = yaml.load(runHelper('searchInvoices'));
|
|
expectRecord(searchGenerated, 'Generated OpenAPI redteam GET config');
|
|
const [searchTarget] = searchGenerated.targets as unknown[];
|
|
expectRecord(searchTarget, 'Generated OpenAPI redteam GET target');
|
|
expectRecord(searchTarget.config, 'Generated OpenAPI redteam GET target config');
|
|
expect(searchTarget.config.method).toBe('GET');
|
|
expect(searchTarget.config.url).toBe('{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/search');
|
|
expect(searchTarget.config.body).toBeUndefined();
|
|
expect(searchTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(searchTarget.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
});
|
|
expect(searchTarget.config.transformResponse).toBe('json.answer');
|
|
expect(searchTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
expect(searchGenerated.defaultTest).toEqual({
|
|
vars: {
|
|
q: 'Say exactly PONG.',
|
|
user_id: 'sample-user-id',
|
|
},
|
|
});
|
|
|
|
const questionGenerated = yaml.load(runHelper('askInvoiceQuestion'));
|
|
expectRecord(questionGenerated, 'Generated OpenAPI redteam question config');
|
|
const [questionTarget] = questionGenerated.targets as unknown[];
|
|
expectRecord(questionTarget, 'Generated OpenAPI redteam question target');
|
|
expectRecord(questionTarget.config, 'Generated OpenAPI redteam question target config');
|
|
expect(questionTarget.config.method).toBe('POST');
|
|
expect(questionTarget.config.url).toBe('{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/ask');
|
|
expect(questionTarget.config.body).toEqual({
|
|
tenant_id: '{{tenant_id}}',
|
|
question: '{{question}}',
|
|
});
|
|
expect(questionTarget.config.transformResponse).toBe('json.answer');
|
|
expect(questionTarget.inputs).toEqual({
|
|
tenant_id: 'Caller identity or tenancy field: tenant_id.',
|
|
question: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(questionGenerated.defaultTest).toEqual({
|
|
vars: {
|
|
tenant_id: 'sample-tenant-id',
|
|
question: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
|
|
const apiKeyGenerated = yaml.load(
|
|
runHelper('searchInvoices', ['--auth-header', 'X-API-Key', '--auth-prefix', 'none']),
|
|
);
|
|
expectRecord(apiKeyGenerated, 'Generated OpenAPI redteam API key config');
|
|
const [apiKeyTarget] = apiKeyGenerated.targets as unknown[];
|
|
expectRecord(apiKeyTarget, 'Generated OpenAPI redteam API key target');
|
|
expectRecord(apiKeyTarget.config, 'Generated OpenAPI redteam API key target config');
|
|
expect(apiKeyTarget.config.headers).toEqual({
|
|
'X-API-Key': '{{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
});
|
|
|
|
const inferredApiKeyGenerated = yaml.load(
|
|
runHelper('apiKeySearchInvoices', ['--token-env', 'OPENAPI_INVOICE_API_KEY']),
|
|
);
|
|
expectRecord(inferredApiKeyGenerated, 'Generated inferred OpenAPI redteam API key config');
|
|
const [inferredApiKeyTarget] = inferredApiKeyGenerated.targets as unknown[];
|
|
expectRecord(inferredApiKeyTarget, 'Generated inferred OpenAPI redteam API key target');
|
|
expectRecord(
|
|
inferredApiKeyTarget.config,
|
|
'Generated inferred OpenAPI redteam API key target config',
|
|
);
|
|
expect(inferredApiKeyTarget.config.url).toBe(
|
|
'{{env.OPENAPI_INVOICE_API_BASE_URL}}/v1/invoices/apikey-search',
|
|
);
|
|
expect(inferredApiKeyTarget.config.headers).toEqual({
|
|
'X-API-Key': '{{env.OPENAPI_INVOICE_API_KEY}}',
|
|
});
|
|
|
|
const inferredQueryApiKeyGenerated = yaml.load(
|
|
runHelper('queryApiKeySearchInvoices', ['--token-env', 'OPENAPI_INVOICE_API_KEY']),
|
|
);
|
|
expectRecord(
|
|
inferredQueryApiKeyGenerated,
|
|
'Generated inferred OpenAPI redteam query API key config',
|
|
);
|
|
const [inferredQueryApiKeyTarget] = inferredQueryApiKeyGenerated.targets as unknown[];
|
|
expectRecord(
|
|
inferredQueryApiKeyTarget,
|
|
'Generated inferred OpenAPI redteam query API key target',
|
|
);
|
|
expectRecord(
|
|
inferredQueryApiKeyTarget.config,
|
|
'Generated inferred OpenAPI redteam query API key target config',
|
|
);
|
|
expect(inferredQueryApiKeyTarget.config.headers).toBeUndefined();
|
|
expect(inferredQueryApiKeyTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
user_id: '{{user_id}}',
|
|
api_key: '{{env.OPENAPI_INVOICE_API_KEY}}',
|
|
});
|
|
expect(inferredQueryApiKeyTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
|
|
const inferredCookieGenerated = yaml.load(
|
|
runHelper('cookieSearchInvoices', ['--token-env', 'OPENAPI_INVOICE_SESSION']),
|
|
);
|
|
expectRecord(inferredCookieGenerated, 'Generated inferred OpenAPI redteam cookie config');
|
|
const [inferredCookieTarget] = inferredCookieGenerated.targets as unknown[];
|
|
expectRecord(inferredCookieTarget, 'Generated inferred OpenAPI redteam cookie target');
|
|
expectRecord(
|
|
inferredCookieTarget.config,
|
|
'Generated inferred OpenAPI redteam cookie target config',
|
|
);
|
|
expect(inferredCookieTarget.config.headers).toEqual({
|
|
Cookie: 'invoice_session={{env.OPENAPI_INVOICE_SESSION}}',
|
|
});
|
|
expect(inferredCookieTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(inferredCookieTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
|
|
const conjunctiveAuthTempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'promptfoo-openapi-conjunctive-auth-'),
|
|
);
|
|
try {
|
|
const conjunctiveAuthSpecPath = path.join(conjunctiveAuthTempDir, 'openapi.yaml');
|
|
fs.writeFileSync(conjunctiveAuthSpecPath, yaml.dump(openApiConjunctiveAuthSpec()));
|
|
const conjunctiveAuthGenerated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
'conjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
'--generator-provider',
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(conjunctiveAuthGenerated, 'Generated OpenAPI conjunctive auth redteam config');
|
|
const [conjunctiveAuthTarget] = conjunctiveAuthGenerated.targets as unknown[];
|
|
expectRecord(conjunctiveAuthTarget, 'Generated OpenAPI conjunctive auth redteam target');
|
|
expectRecord(
|
|
conjunctiveAuthTarget.config,
|
|
'Generated OpenAPI conjunctive auth redteam target config',
|
|
);
|
|
expect(conjunctiveAuthTarget.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
Cookie:
|
|
'session_id={{env.CONJUNCTIVE_API_TOKEN}}; csrf_token={{env.CONJUNCTIVE_API_TOKEN}}',
|
|
'X-API-Key': '{{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
expect(conjunctiveAuthTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
});
|
|
expect(conjunctiveAuthTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
});
|
|
|
|
const fallbackConjunctiveAuthGenerated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
'fallbackConjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
'--generator-provider',
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(
|
|
fallbackConjunctiveAuthGenerated,
|
|
'Generated OpenAPI fallback conjunctive auth redteam config',
|
|
);
|
|
const [fallbackConjunctiveAuthTarget] = fallbackConjunctiveAuthGenerated.targets as unknown[];
|
|
expectRecord(
|
|
fallbackConjunctiveAuthTarget,
|
|
'Generated OpenAPI fallback conjunctive auth redteam target',
|
|
);
|
|
expectRecord(
|
|
fallbackConjunctiveAuthTarget.config,
|
|
'Generated OpenAPI fallback conjunctive auth redteam target config',
|
|
);
|
|
expect(fallbackConjunctiveAuthTarget.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
'X-API-Key': '{{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
|
|
const partialConjunctiveAuthGenerated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
'partialConjunctiveSearch',
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
'--generator-provider',
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(
|
|
partialConjunctiveAuthGenerated,
|
|
'Generated OpenAPI partial conjunctive auth redteam config',
|
|
);
|
|
const [partialConjunctiveAuthTarget] = partialConjunctiveAuthGenerated.targets as unknown[];
|
|
expectRecord(
|
|
partialConjunctiveAuthTarget,
|
|
'Generated OpenAPI partial conjunctive auth redteam target',
|
|
);
|
|
expectRecord(
|
|
partialConjunctiveAuthTarget.config,
|
|
'Generated OpenAPI partial conjunctive auth redteam target config',
|
|
);
|
|
expect(partialConjunctiveAuthTarget.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.CONJUNCTIVE_API_TOKEN}}',
|
|
});
|
|
expect(partialConjunctiveAuthTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
});
|
|
|
|
for (const operationId of [
|
|
'explicitNoAuthSearch',
|
|
'emptyRequirementSearch',
|
|
'unsupportedAuthSearch',
|
|
]) {
|
|
const noAuthGenerated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(
|
|
redteamSetupSkillRoot,
|
|
'scripts',
|
|
'openapi-operation-to-redteam-config.mjs',
|
|
),
|
|
'--spec',
|
|
conjunctiveAuthSpecPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'CONJUNCTIVE_API_BASE_URL',
|
|
'--token-env',
|
|
'CONJUNCTIVE_API_TOKEN',
|
|
'--generator-provider',
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(noAuthGenerated, `Generated OpenAPI ${operationId} redteam config`);
|
|
const [noAuthTarget] = noAuthGenerated.targets as unknown[];
|
|
expectRecord(noAuthTarget, `Generated OpenAPI ${operationId} redteam target`);
|
|
expectRecord(noAuthTarget.config, `Generated OpenAPI ${operationId} redteam target config`);
|
|
expect(noAuthTarget.config.headers).toBeUndefined();
|
|
expect(noAuthTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
});
|
|
expect(noAuthTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
});
|
|
}
|
|
} finally {
|
|
fs.rmSync(conjunctiveAuthTempDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const oauthTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-oauth-'));
|
|
try {
|
|
const oauthSpecPath = path.join(oauthTempDir, 'openapi.yaml');
|
|
fs.writeFileSync(oauthSpecPath, yaml.dump(openApiOAuthSecuritySpec()));
|
|
const runOAuthRedteamHelper = (operationId: string, tokenEnv: string) =>
|
|
yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(
|
|
redteamSetupSkillRoot,
|
|
'scripts',
|
|
'openapi-operation-to-redteam-config.mjs',
|
|
),
|
|
'--spec',
|
|
oauthSpecPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'OAUTH_API_BASE_URL',
|
|
'--token-env',
|
|
tokenEnv,
|
|
'--generator-provider',
|
|
'file://test/fixtures/agent-skills/redteam-setup-live-http/openapi-redteam-generator.mjs',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
|
|
const oauthGenerated = runOAuthRedteamHelper('oauthSearch', 'OAUTH_ACCESS_TOKEN');
|
|
expectRecord(oauthGenerated, 'Generated OpenAPI OAuth2 redteam config');
|
|
const [oauthTarget] = oauthGenerated.targets as unknown[];
|
|
expectRecord(oauthTarget, 'Generated OpenAPI OAuth2 redteam target');
|
|
expectRecord(oauthTarget.config, 'Generated OpenAPI OAuth2 redteam target config block');
|
|
expect(oauthTarget.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.OAUTH_ACCESS_TOKEN}}',
|
|
});
|
|
expect(oauthTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(oauthTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
|
|
const openIdGenerated = runOAuthRedteamHelper('openIdSearch', 'OPENID_ACCESS_TOKEN');
|
|
expectRecord(openIdGenerated, 'Generated OpenAPI OpenID redteam config');
|
|
const [openIdTarget] = openIdGenerated.targets as unknown[];
|
|
expectRecord(openIdTarget, 'Generated OpenAPI OpenID redteam target');
|
|
expectRecord(openIdTarget.config, 'Generated OpenAPI OpenID redteam target config block');
|
|
expect(openIdTarget.config.headers).toEqual({
|
|
Authorization: 'Bearer {{env.OPENID_ACCESS_TOKEN}}',
|
|
});
|
|
expect(openIdTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
});
|
|
expect(openIdTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
});
|
|
} finally {
|
|
fs.rmSync(oauthTempDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const headerGenerated = yaml.load(runHelper('headerSearchInvoices'));
|
|
expectRecord(headerGenerated, 'Generated OpenAPI redteam header parameter config');
|
|
const [headerTarget] = headerGenerated.targets as unknown[];
|
|
expectRecord(headerTarget, 'Generated OpenAPI redteam header parameter target');
|
|
expectRecord(headerTarget.config, 'Generated OpenAPI redteam header parameter target config');
|
|
expect(headerTarget.config.headers).toEqual({
|
|
'X-Tenant-Id': '{{x_tenant_id}}',
|
|
Authorization: 'Bearer {{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
});
|
|
expect(headerTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
user_id: '{{user_id}}',
|
|
'api-version': '{{api_version}}',
|
|
});
|
|
expect(headerTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
api_version: 'Target input field: api_version.',
|
|
x_tenant_id: 'Caller identity or tenancy field: x_tenant_id.',
|
|
});
|
|
expect(headerGenerated.defaultTest).toEqual({
|
|
vars: {
|
|
q: 'Say exactly PONG.',
|
|
user_id: 'sample-user-id',
|
|
api_version: '2026-04-01',
|
|
x_tenant_id: 'tenant-alpha',
|
|
},
|
|
});
|
|
expect(headerGenerated.tests).toBeUndefined();
|
|
|
|
const cookieParamGenerated = yaml.load(runHelper('cookieParamSearchInvoices'));
|
|
expectRecord(cookieParamGenerated, 'Generated OpenAPI redteam cookie parameter config');
|
|
const [cookieParamTarget] = cookieParamGenerated.targets as unknown[];
|
|
expectRecord(cookieParamTarget, 'Generated OpenAPI redteam cookie parameter target');
|
|
expectRecord(
|
|
cookieParamTarget.config,
|
|
'Generated OpenAPI redteam cookie parameter target config',
|
|
);
|
|
expect(cookieParamTarget.config.headers).toEqual({
|
|
Cookie: 'invoice-context={{invoice_context}}',
|
|
Authorization: 'Bearer {{env.OPENAPI_INVOICE_API_TOKEN}}',
|
|
});
|
|
expect(cookieParamTarget.config.queryParams).toEqual({
|
|
q: '{{q}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(cookieParamTarget.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
invoice_context: 'Target input field: invoice_context.',
|
|
});
|
|
expect(cookieParamGenerated.defaultTest).toEqual({
|
|
vars: {
|
|
q: 'Say exactly PONG.',
|
|
user_id: 'sample-user-id',
|
|
invoice_context: 'context-alpha',
|
|
},
|
|
});
|
|
|
|
const headerSmokeGenerated = yaml.load(
|
|
runHelper('headerSearchInvoices', ['--smoke-test', 'true', '--smoke-assert', 'PONG']),
|
|
);
|
|
expectRecord(headerSmokeGenerated, 'Generated OpenAPI redteam smoke config');
|
|
expect(headerSmokeGenerated.tests).toEqual([
|
|
{
|
|
description: 'headerSearchInvoices smoke test',
|
|
vars: {
|
|
q: 'Say exactly PONG.',
|
|
user_id: 'sample-user-id',
|
|
api_version: '2026-04-01',
|
|
x_tenant_id: 'tenant-alpha',
|
|
},
|
|
assert: [{ type: 'contains', value: 'PONG' }],
|
|
},
|
|
]);
|
|
|
|
const tunedGenerated = yaml.load(
|
|
runHelper('createInvoiceNote', [
|
|
'--policy',
|
|
'Custom invoice policy: only authorized users may create invoice notes.',
|
|
'--num-tests',
|
|
'3',
|
|
]),
|
|
);
|
|
expectRecord(tunedGenerated, 'Generated OpenAPI tuned redteam config');
|
|
expectRecord(tunedGenerated.redteam, 'Generated OpenAPI tuned redteam block');
|
|
expect(tunedGenerated.redteam.purpose).toBe(
|
|
'Custom invoice policy: only authorized users may create invoice notes.',
|
|
);
|
|
expect(tunedGenerated.redteam.numTests).toBe(3);
|
|
expect(tunedGenerated.redteam.plugins).toEqual([
|
|
expect.objectContaining({
|
|
id: 'policy',
|
|
numTests: 3,
|
|
config: {
|
|
policy: 'Custom invoice policy: only authorized users may create invoice notes.',
|
|
},
|
|
}),
|
|
{ id: 'rbac', numTests: 3 },
|
|
{ id: 'bola', numTests: 3 },
|
|
]);
|
|
});
|
|
|
|
it('fails clearly for invalid redteam OpenAPI helper num-tests values', () => {
|
|
expect(() =>
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
path.join(fixtureRoot, 'provider-setup-openapi', 'openapi.yaml'),
|
|
'--operation-id',
|
|
'createInvoiceNote',
|
|
'--base-url-env',
|
|
'OPENAPI_INVOICE_API_BASE_URL',
|
|
'--num-tests',
|
|
'0',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' },
|
|
),
|
|
).toThrow('--num-tests must be a positive integer');
|
|
});
|
|
|
|
it('keeps redteam OpenAPI helper plugin inference conservative without identity fields', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/moderate': {
|
|
post: {
|
|
operationId: 'moderateText',
|
|
requestBody: {
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['text'],
|
|
properties: {
|
|
text: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Moderation response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
output: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'moderateText',
|
|
'--base-url-env',
|
|
'MODERATION_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated no-identity redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated no-identity redteam target');
|
|
expect(target.inputs).toEqual({
|
|
text: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expectRecord(target.config, 'Generated no-identity target config');
|
|
expect(target.config.body).toEqual({ text: '{{text}}' });
|
|
expect(generated.defaultTest).toEqual({ vars: { text: 'Say exactly PONG.' } });
|
|
expectRecord(generated.redteam, 'Generated no-identity redteam block');
|
|
expect(generated.redteam.plugins).toEqual([
|
|
expect.objectContaining({
|
|
id: 'policy',
|
|
numTests: 1,
|
|
}),
|
|
]);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('prefers OpenAPI parameter and media examples for redteam default vars', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExamplePrecedenceSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createExampleNote',
|
|
'--base-url-env',
|
|
'EXAMPLE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example precedence redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated example precedence redteam target');
|
|
expectRecord(target.config, 'Generated example precedence redteam target config');
|
|
expect(target.config.url).toBe(
|
|
'{{env.EXAMPLE_API_BASE_URL}}/example/{{invoice_id | urlencode}}',
|
|
);
|
|
expect(target.config.queryParams).toEqual({
|
|
'tenant-id': '{{tenant_id}}',
|
|
});
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
note_type: '{{note_type}}',
|
|
});
|
|
expect(target.inputs).toEqual({
|
|
invoice_id: 'Object identifier being requested: invoice_id.',
|
|
tenant_id: 'Caller identity or tenancy field: tenant_id.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
note_type: 'Target input field: note_type.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
invoice_id: 'invoice-from-parameter-example',
|
|
tenant_id: 'tenant-from-example-ref',
|
|
user_id: 'user-from-media-example',
|
|
message: 'Say exactly PONG.',
|
|
note_type: 'note-type-from-media-example',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses vendor +json OpenAPI media types for redteam request and response schemas', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiVendorJsonSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createVendorJsonNote',
|
|
'--base-url-env',
|
|
'VENDOR_JSON_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated vendor JSON redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated vendor JSON redteam target');
|
|
expectRecord(target.config, 'Generated vendor JSON redteam target config');
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': 'application/vnd.promptfoo.note+json',
|
|
});
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.answer');
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'vendor-json-user',
|
|
message: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI form-urlencoded request bodies for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiFormUrlEncodedSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'formChat',
|
|
'--base-url-env',
|
|
'FORM_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated form redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated form redteam target');
|
|
expectRecord(target.config, 'Generated form redteam target config');
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
});
|
|
expect(target.config.body).toBe(
|
|
'user_id={{user_id | urlencode}}&message={{message | urlencode}}&scope={{scope | urlencode}}',
|
|
);
|
|
expect(target.config.transformResponse).toBe('json.answer');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
scope: 'Target input field: scope.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'form-user',
|
|
message: 'Say exactly PONG.',
|
|
scope: 'billing support',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI multipart request bodies for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiMultipartSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'analyzeFile',
|
|
'--base-url-env',
|
|
'MULTIPART_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated multipart redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated multipart redteam target');
|
|
expectRecord(target.config, 'Generated multipart redteam target config');
|
|
expect(target.config.headers).toBeUndefined();
|
|
expect(target.config.body).toBeUndefined();
|
|
expect(target.config.multipart).toEqual({
|
|
parts: [
|
|
{
|
|
kind: 'file',
|
|
name: 'document',
|
|
filename: 'promptfoo-document.pdf',
|
|
source: {
|
|
type: 'generated',
|
|
generator: 'basic-document',
|
|
format: 'pdf',
|
|
text: 'Promptfoo generated document for {{document}}.',
|
|
},
|
|
},
|
|
{ kind: 'field', name: 'question', value: '{{question}}' },
|
|
{ kind: 'field', name: 'user_id', value: '{{user_id}}' },
|
|
],
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.output');
|
|
expect(target.inputs).toEqual({
|
|
document: 'Target input field: document.',
|
|
question: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
document: 'sample-document',
|
|
question: 'Say exactly PONG.',
|
|
user_id: 'multipart-user',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI text request bodies for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiTextPlainSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'textChat',
|
|
'--base-url-env',
|
|
'TEXT_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated text redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated text redteam target');
|
|
expectRecord(target.config, 'Generated text redteam target config');
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': 'text/plain',
|
|
});
|
|
expect(target.config.body).toBe('{{message}}');
|
|
expect(target.config.transformResponse).toBe('json.output');
|
|
expect(target.inputs).toEqual({
|
|
message: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
message: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves scalar OpenAPI request examples without schemas for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiScalarExampleRequestSpec()));
|
|
for (const [operationId, contentType] of [
|
|
['jsonScalarChat', 'application/json'],
|
|
['jsonScalarSchemaChat', 'application/json'],
|
|
['textScalarChat', 'text/plain'],
|
|
]) {
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(
|
|
redteamSetupSkillRoot,
|
|
'scripts',
|
|
'openapi-operation-to-redteam-config.mjs',
|
|
),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'SCALAR_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, `Generated ${operationId} redteam config`);
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, `Generated ${operationId} redteam target`);
|
|
expectRecord(target.config, `Generated ${operationId} redteam target config`);
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': contentType,
|
|
});
|
|
expect(target.config.body).toBe('{{message}}');
|
|
expect(target.config.transformResponse).toBe('json.output');
|
|
expect(target.inputs).toEqual({
|
|
message: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
message: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
}
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI root array request bodies for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiArrayRequestSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'batchChat',
|
|
'--base-url-env',
|
|
'BATCH_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated array redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated array redteam target');
|
|
expectRecord(target.config, 'Generated array redteam target config');
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
});
|
|
expect(target.config.body).toEqual([
|
|
{
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
priority: '{{priority}}',
|
|
},
|
|
]);
|
|
expect(target.config.transformResponse).toBe('json.output');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
priority: 'Target input field: priority.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'array-user',
|
|
message: 'Say exactly PONG.',
|
|
priority: 'normal',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves OpenAPI example-only array request bodies for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExampleOnlyArrayBodySpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'exampleArrayChat',
|
|
'--base-url-env',
|
|
'EXAMPLE_ARRAY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example-only array redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated example-only array redteam target');
|
|
expectRecord(target.config, 'Generated example-only array redteam target config');
|
|
expect(target.config.body).toEqual([
|
|
{
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
priority: '{{priority}}',
|
|
},
|
|
]);
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
priority: 'Target input field: priority.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'example-array-user',
|
|
message: 'Say exactly PONG.',
|
|
priority: 'expedite',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('extracts first-item fields from OpenAPI array responses for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiArrayResponseSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'arrayResponseChat',
|
|
'--base-url-env',
|
|
'ARRAY_RESPONSE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated array-response redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated array-response redteam target');
|
|
expectRecord(target.config, 'Generated array-response redteam target config');
|
|
expect(target.config.body).toEqual({
|
|
message: '{{message}}',
|
|
user_id: '{{user_id}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json[0].output');
|
|
expect(target.inputs).toEqual({
|
|
message: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
message: 'Say exactly PONG.',
|
|
user_id: 'array-response-user',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('extracts first-item fields from OpenAPI example-only array responses for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExampleOnlyArrayResponseSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'exampleArrayResponseChat',
|
|
'--base-url-env',
|
|
'EXAMPLE_ARRAY_RESPONSE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example-only array-response redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated example-only array-response redteam target');
|
|
expectRecord(target.config, 'Generated example-only array-response redteam target config');
|
|
expect(target.config.transformResponse).toBe('json[0].output');
|
|
expect(target.inputs).toEqual({
|
|
message: 'User-controlled message or instruction to the target.',
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
message: 'Say exactly PONG.',
|
|
user_id: 'example-array-response-user',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('quotes unsafe OpenAPI response field accessors for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiUnsafeResponseFieldSpec()));
|
|
const runHelper = (operationId: string) =>
|
|
yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(
|
|
redteamSetupSkillRoot,
|
|
'scripts',
|
|
'openapi-operation-to-redteam-config.mjs',
|
|
),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
operationId,
|
|
'--base-url-env',
|
|
'UNSAFE_RESPONSE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
|
|
const objectGenerated = runHelper('unsafeResponseField');
|
|
expectRecord(objectGenerated, 'Generated unsafe response field redteam config');
|
|
const [objectTarget] = objectGenerated.targets as unknown[];
|
|
expectRecord(objectTarget, 'Generated unsafe response field redteam target');
|
|
expectRecord(objectTarget.config, 'Generated unsafe response field redteam config block');
|
|
expect(objectTarget.config.transformResponse).toBe('json["api-version"]');
|
|
|
|
const arrayGenerated = runHelper('unsafeArrayResponseField');
|
|
expectRecord(arrayGenerated, 'Generated unsafe array response field redteam config');
|
|
const [arrayTarget] = arrayGenerated.targets as unknown[];
|
|
expectRecord(arrayTarget, 'Generated unsafe array response field redteam target');
|
|
expectRecord(
|
|
arrayTarget.config,
|
|
'Generated unsafe array response field redteam config block',
|
|
);
|
|
expect(arrayTarget.config.transformResponse).toBe('json[0]["200"]');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('merges OpenAPI allOf request and response schemas for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiAllOfSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createAllOfNote',
|
|
'--base-url-env',
|
|
'ALLOF_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated allOf redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated allOf redteam target');
|
|
expectRecord(target.config, 'Generated allOf redteam target config');
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
note_type: '{{note_type}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.answer');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
note_type: 'Target input field: note_type.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'allof-user',
|
|
message: 'Say exactly PONG.',
|
|
note_type: 'internal-note',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('allows repeated non-cyclic OpenAPI refs for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiRepeatedRefSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createRepeatedRefNote',
|
|
'--base-url-env',
|
|
'REPEATED_REF_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated repeated-ref redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated repeated-ref redteam target');
|
|
expectRecord(target.config, 'Generated repeated-ref redteam target config');
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.answer');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'repeat-user',
|
|
message: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('normalizes camelCase OpenAPI identifiers for redteam RBAC inference', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiCamelCaseIdSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'chatWithCamelCaseInvoice',
|
|
'--base-url-env',
|
|
'CAMEL_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated camelCase redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated camelCase redteam target');
|
|
expectRecord(target.config, 'Generated camelCase redteam target config');
|
|
expect(target.config.url).toBe(
|
|
'{{env.CAMEL_API_BASE_URL}}/users/{{user_id | urlencode}}/invoices/{{invoice_id | urlencode}}/chat',
|
|
);
|
|
expect(target.config.queryParams).toEqual({
|
|
apiVersion: '{{api_version}}',
|
|
});
|
|
expect(target.config.body).toEqual({
|
|
accountId: '{{account_id}}',
|
|
message: '{{message}}',
|
|
});
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
invoice_id: 'Object identifier being requested: invoice_id.',
|
|
api_version: 'Target input field: api_version.',
|
|
account_id: 'Caller identity or tenancy field: account_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'camel-user',
|
|
invoice_id: 'camel-invoice',
|
|
api_version: '2026-04-17',
|
|
account_id: 'camel-account',
|
|
message: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
expectRecord(generated.redteam, 'Generated camelCase redteam block');
|
|
expect(generated.redteam.plugins).toEqual([
|
|
expect.objectContaining({ id: 'policy', numTests: 1 }),
|
|
{ id: 'rbac', numTests: 1 },
|
|
{ id: 'bola', numTests: 1 },
|
|
]);
|
|
const [policyPlugin] = generated.redteam.plugins as unknown[];
|
|
expectRecord(policyPlugin, 'Generated camelCase policy plugin');
|
|
expectRecord(policyPlugin.config, 'Generated camelCase policy config');
|
|
expect(policyPlugin.config.policy).toContain('user_id');
|
|
expect(policyPlugin.config.policy).toContain('invoice_id');
|
|
expect(policyPlugin.config.policy).toContain('account_id');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses the first OpenAPI oneOf/anyOf variant for redteam config drafts', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiVariantSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'variantSearch',
|
|
'--base-url-env',
|
|
'VARIANT_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated variant redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated variant redteam target');
|
|
expectRecord(target.config, 'Generated variant redteam target config');
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
query: '{{query}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.answer');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
query: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'variant-user',
|
|
query: 'Say exactly PONG.',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI request body examples when redteam schemas are omitted', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiExampleOnlyBodySpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createExampleOnlyNote',
|
|
'--base-url-env',
|
|
'EXAMPLE_ONLY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated example-only redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated example-only redteam target');
|
|
expectRecord(target.config, 'Generated example-only redteam target config');
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
});
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
note_type: '{{note_type}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.answer');
|
|
expect(target.inputs).toEqual({
|
|
user_id: 'Caller identity or tenancy field: user_id.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
note_type: 'Target input field: note_type.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'example-user',
|
|
message: 'Say exactly PONG.',
|
|
note_type: 'support-note',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses OpenAPI schema types for redteam default vars', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiTypedSchemaSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'createTypedNote',
|
|
'--base-url-env',
|
|
'TYPED_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated typed redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated typed redteam target');
|
|
expectRecord(target.config, 'Generated typed redteam target config');
|
|
expect(target.config.body).toEqual({
|
|
user_id: '{{user_id}}',
|
|
message: '{{message}}',
|
|
limit: '{{limit}}',
|
|
include_archived: '{{include_archived}}',
|
|
tags: '{{tags}}',
|
|
metadata: '{{metadata}}',
|
|
status: '{{status}}',
|
|
contact_email: '{{contact_email}}',
|
|
callback_url: '{{callback_url}}',
|
|
request_id: '{{request_id}}',
|
|
scheduled_date: '{{scheduled_date}}',
|
|
created_at: '{{created_at}}',
|
|
confidence: '{{confidence}}',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
user_id: 'typed-user',
|
|
message: 'Say exactly PONG.',
|
|
limit: 1,
|
|
include_archived: true,
|
|
tags: ['finance'],
|
|
metadata: {
|
|
region: 'us-west',
|
|
priority: 1,
|
|
},
|
|
status: 'queued',
|
|
contact_email: 'user@example.com',
|
|
callback_url: 'https://example.test/resource',
|
|
request_id: '00000000-0000-4000-8000-000000000000',
|
|
scheduled_date: '2026-04-17',
|
|
created_at: '2026-04-17T00:00:00Z',
|
|
confidence: 1,
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('lets OpenAPI operation parameters override path-item parameters for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiParameterOverrideSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'overrideParameters',
|
|
'--base-url-env',
|
|
'OVERRIDE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated override redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated override redteam target');
|
|
expectRecord(target.config, 'Generated override redteam target config');
|
|
expect(target.config.url).toBe(
|
|
'{{env.OVERRIDE_API_BASE_URL}}/override/{{resource_id | urlencode}}',
|
|
);
|
|
expect(target.config.queryParams).toEqual({
|
|
'api-version': '{{api_version}}',
|
|
});
|
|
expect(target.config.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
'X-Trace-Id': '{{x_trace_id}}',
|
|
});
|
|
expect(target.inputs).toEqual({
|
|
resource_id: 'Object identifier being requested: resource_id.',
|
|
api_version: 'Target input field: api_version.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
x_trace_id: 'Target input field: x_trace_id.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
resource_id: 'resource-123',
|
|
api_version: 'operation-version',
|
|
message: 'Say exactly PONG.',
|
|
x_trace_id: 'operation-trace',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips OpenAPI writeOnly response fields for redteam response transforms', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiWriteOnlyResponseSpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'writeOnlyResponse',
|
|
'--base-url-env',
|
|
'WRITE_ONLY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated writeOnly response redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated writeOnly response redteam target');
|
|
expectRecord(target.config, 'Generated writeOnly response redteam target config');
|
|
expect(target.config.transformResponse).toBe('json.public_text');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('honors composed OpenAPI visibility flags for redteam configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(specPath, yaml.dump(openApiComposedVisibilitySpec()));
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'composedVisibility',
|
|
'--base-url-env',
|
|
'COMPOSED_VISIBILITY_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated composed visibility redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated composed visibility redteam target');
|
|
expectRecord(target.config, 'Generated composed visibility redteam target config');
|
|
expect(target.config.body).toEqual({
|
|
message: '{{message}}',
|
|
client_note: '{{client_note}}',
|
|
});
|
|
expect(target.config.transformResponse).toBe('json.public_text');
|
|
expect(target.inputs).toEqual({
|
|
message: 'User-controlled message or instruction to the target.',
|
|
client_note: 'Target input field: client_note.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({
|
|
vars: {
|
|
message: 'Say exactly PONG.',
|
|
client_note: 'sample-client_note',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails clearly for unsupported external OpenAPI refs in the redteam helper', () => {
|
|
expectOpenApiHelperFailure(
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
{
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/broken': {
|
|
get: {
|
|
operationId: 'brokenOperation',
|
|
responses: {
|
|
'200': {
|
|
description: 'Broken response',
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: 'https://example.test/schemas/Response.yaml' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'Only local OpenAPI refs are supported',
|
|
);
|
|
});
|
|
|
|
it('fails clearly for circular OpenAPI refs in the redteam helper', () => {
|
|
expectOpenApiHelperFailure(
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
{
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/broken': {
|
|
post: {
|
|
operationId: 'brokenOperation',
|
|
requestBody: {
|
|
content: {
|
|
'application/json': {
|
|
schema: { $ref: '#/components/schemas/A' },
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Broken response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
components: {
|
|
schemas: {
|
|
A: { $ref: '#/components/schemas/B' },
|
|
B: { $ref: '#/components/schemas/A' },
|
|
},
|
|
},
|
|
},
|
|
'Circular OpenAPI ref detected',
|
|
);
|
|
});
|
|
|
|
it('does not misclassify non-_id user-like fields as identity fields for RBAC', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-non-id-identity-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/profile/update': {
|
|
post: {
|
|
operationId: 'updateProfile',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['user_name', 'message'],
|
|
properties: {
|
|
user_name: { type: 'string' },
|
|
user_agent: { type: 'string' },
|
|
org_name: { type: 'string' },
|
|
message: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Update response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'updateProfile',
|
|
'--base-url-env',
|
|
'PROFILE_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated non-_id identity redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated non-_id identity redteam target');
|
|
expect(target.inputs).toEqual({
|
|
user_name: 'Target input field: user_name.',
|
|
user_agent: 'Target input field: user_agent.',
|
|
org_name: 'Target input field: org_name.',
|
|
message: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expectRecord(generated.redteam, 'Generated non-_id identity redteam block');
|
|
// No identity or object _id fields present => only the policy plugin is inferred.
|
|
expect(generated.redteam.plugins).toEqual([
|
|
expect.objectContaining({ id: 'policy', numTests: 1 }),
|
|
]);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('adds the bola plugin when object identifiers are present', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-bola-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/loans/{loan_id}/advise': {
|
|
post: {
|
|
operationId: 'adviseOnLoan',
|
|
parameters: [
|
|
{ name: 'loan_id', in: 'path', required: true, schema: { type: 'string' } },
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['user_id', 'question'],
|
|
properties: {
|
|
user_id: { type: 'string' },
|
|
question: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Advice',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { advice: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'adviseOnLoan',
|
|
'--base-url-env',
|
|
'LOAN_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated object-id redteam config');
|
|
expectRecord(generated.redteam, 'Generated object-id redteam block');
|
|
// loan_id is an object identifier (not identity, not technical) so bola should be inferred
|
|
// alongside rbac (driven by user_id). This matches the skill's
|
|
// "object IDs imply bola" guidance.
|
|
expect(generated.redteam.plugins).toEqual([
|
|
expect.objectContaining({ id: 'policy', numTests: 1 }),
|
|
{ id: 'rbac', numTests: 1 },
|
|
{ id: 'bola', numTests: 1 },
|
|
]);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('forces env placeholders for credential-like params in redteam target configs', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-redteam-credential-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/search': {
|
|
get: {
|
|
operationId: 'leakyRedteamSearch',
|
|
parameters: [
|
|
{
|
|
name: 'Authorization',
|
|
in: 'header',
|
|
schema: { type: 'string', example: 'Bearer SECRET_TOKEN_123' },
|
|
},
|
|
{
|
|
name: 'api_key',
|
|
in: 'query',
|
|
schema: { type: 'string', example: 'PROD_API_KEY_456' },
|
|
},
|
|
{
|
|
name: 'session_id',
|
|
in: 'cookie',
|
|
schema: { type: 'string', example: 'sess_XYZ' },
|
|
},
|
|
{ name: 'q', in: 'query', schema: { type: 'string' } },
|
|
],
|
|
responses: {
|
|
'200': {
|
|
description: 'ok',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'leakyRedteamSearch',
|
|
'--base-url-env',
|
|
'LEAKY_REDTEAM_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated credential-leak redteam config');
|
|
const [target] = generated.targets as unknown[];
|
|
expectRecord(target, 'Generated credential-leak redteam target');
|
|
expectRecord(target.config, 'Generated credential-leak redteam target config');
|
|
expect(target.config.headers).toEqual({
|
|
Authorization: '{{env.AUTHORIZATION}}',
|
|
Cookie: 'session_id={{env.SESSION_ID}}',
|
|
});
|
|
expect(target.config.queryParams).toEqual({
|
|
api_key: '{{env.API_KEY}}',
|
|
q: '{{q}}',
|
|
});
|
|
// Credentials must NOT appear as target inputs or default vars.
|
|
expect(target.inputs).toEqual({
|
|
q: 'User-controlled message or instruction to the target.',
|
|
});
|
|
expect(generated.defaultTest).toEqual({ vars: { q: 'Say exactly PONG.' } });
|
|
const serialized = yaml.dump(generated);
|
|
expect(serialized).not.toContain('SECRET_TOKEN_123');
|
|
expect(serialized).not.toContain('PROD_API_KEY_456');
|
|
expect(serialized).not.toContain('sess_XYZ');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('excludes technical tracing _id fields from RBAC object-field inference', () => {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-openapi-technical-id-'));
|
|
const specPath = path.join(tempDir, 'openapi.yaml');
|
|
try {
|
|
fs.writeFileSync(
|
|
specPath,
|
|
yaml.dump({
|
|
openapi: '3.1.0',
|
|
paths: {
|
|
'/moderate': {
|
|
post: {
|
|
operationId: 'moderateText',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
required: ['trace_id', 'text'],
|
|
properties: {
|
|
trace_id: { type: 'string' },
|
|
request_id: { type: 'string' },
|
|
span_id: { type: 'string' },
|
|
text: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
'200': {
|
|
description: 'Moderation response',
|
|
content: {
|
|
'application/json': {
|
|
schema: {
|
|
type: 'object',
|
|
properties: { output: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const generated = yaml.load(
|
|
execFileSync(
|
|
'node',
|
|
[
|
|
path.join(redteamSetupSkillRoot, 'scripts', 'openapi-operation-to-redteam-config.mjs'),
|
|
'--spec',
|
|
specPath,
|
|
'--operation-id',
|
|
'moderateText',
|
|
'--base-url-env',
|
|
'MODERATION_API_BASE_URL',
|
|
],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
),
|
|
);
|
|
expectRecord(generated, 'Generated technical-id redteam config');
|
|
expectRecord(generated.redteam, 'Generated technical-id redteam block');
|
|
// Only technical trace/request/span IDs are present (no object _id, no identity);
|
|
// the rbac plugin should NOT be inferred.
|
|
expect(generated.redteam.plugins).toEqual([
|
|
expect.objectContaining({ id: 'policy', numTests: 1 }),
|
|
]);
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('promptfoo-redteam-run skill', () => {
|
|
it('has a routing description with run scope and setup/provider boundaries', () => {
|
|
const skill = readText(path.join(redteamRunSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toMatch(/^---\nname: promptfoo-redteam-run\n/);
|
|
expect(skill).toContain('Run, rerun, inspect, and QA');
|
|
expect(skill).toContain('generated redteam YAML');
|
|
expect(skill).toContain('attack success rate');
|
|
expect(skill).toContain('Do not use for initial provider wiring');
|
|
expect(skill).toContain('choosing plugins');
|
|
expect(skill).toContain('strategies before generation');
|
|
});
|
|
|
|
it('documents reproducible redteam eval, inspection, and filtered reruns', () => {
|
|
const skill = readText(path.join(redteamRunSkillRoot, 'SKILL.md'));
|
|
|
|
expect(skill).toContain('Use `redteam eval`');
|
|
expect(skill).toContain('Use `redteam run --force`');
|
|
expect(skill).toContain('validate target');
|
|
expect(skill).toContain('--no-cache --no-share --no-progress-bar');
|
|
expect(skill).toContain('results.stats.successes');
|
|
expect(skill).toContain('shareableUrl');
|
|
expect(skill).toContain('--filter-failing');
|
|
expect(skill).toContain('--filter-errors-only');
|
|
expect(skill).toContain('--filter-metadata pluginId=policy');
|
|
expect(skill).toContain('ENOENT');
|
|
expect(skill).toContain('Regenerate beside');
|
|
expect(skill).toContain('redteam report');
|
|
// `redteam run` has no --no-share flag (see src/redteam/commands/run.ts);
|
|
// the skill must route users to PROMPTFOO_DISABLE_SHARING=true instead.
|
|
expect(skill).toContain('PROMPTFOO_DISABLE_SHARING=true');
|
|
expect(skill).not.toMatch(/redteam run[^\n]*--no-share/);
|
|
});
|
|
|
|
it('ships Codex UI metadata for redteam runs', () => {
|
|
const openaiYaml = readText(path.join(redteamRunSkillRoot, 'agents', 'openai.yaml'));
|
|
|
|
expect(openaiYaml).toContain('Promptfoo Redteam Run');
|
|
expect(openaiYaml).toContain('$promptfoo-redteam-run');
|
|
expect(openaiYaml).toContain('allow_implicit_invocation: true');
|
|
});
|
|
|
|
it('keeps run commands and CI gates in a progressive-disclosure reference file', () => {
|
|
const reference = readText(
|
|
path.join(redteamRunSkillRoot, 'references', 'redteam-run-patterns.md'),
|
|
);
|
|
|
|
expect(reference).toContain('Stable Eval From Generated Tests');
|
|
expect(reference).toContain('Generate And Evaluate');
|
|
expect(reference).toContain('Deterministic Grader QA');
|
|
expect(reference).toContain('Run the generated probes with the deterministic grader');
|
|
expect(reference).toContain('INTENTIONAL_LEAK');
|
|
expect(reference).toContain('resolves under `/tmp`');
|
|
expect(reference).toContain('file://test/fixtures/my-scan/target.mjs');
|
|
expect(reference).toContain('file://./target.py:call_api');
|
|
expect(reference).toContain('file://grader.py:grade_redteam');
|
|
expect(reference).toContain('file://target.py:function_name');
|
|
expect(reference).toContain('call_api(prompt, options, context)');
|
|
expect(reference).toContain('anchor `sys.path`');
|
|
expect(reference).toContain('Path(__file__).resolve().parent');
|
|
expect(reference).toContain('constructor `options.config`');
|
|
expect(reference).toContain('options` argument to `call_api`');
|
|
expect(reference).toContain('workers: 1');
|
|
expect(reference).toContain('timeout');
|
|
expect(reference).toContain('jq');
|
|
expect(reference).toContain('--filter-failing');
|
|
expect(reference).toContain('--filter-errors-only');
|
|
expect(reference).toContain('PROMPTFOO_FAILED_TEST_EXIT_CODE=0');
|
|
});
|
|
|
|
for (const dir of redteamRunFixtureDirs) {
|
|
it(`keeps generated redteam metadata and assertion shape intact for ${dir}`, () => {
|
|
const config = yaml.load(readText(path.join(fixtureRoot, dir, 'redteam.yaml')));
|
|
expectRecord(config, `${dir} config`);
|
|
|
|
expect(Array.isArray(config.targets), `${dir} targets`).toBe(true);
|
|
const [target] = config.targets as unknown[];
|
|
expectRecord(target, `${dir} target`);
|
|
const targetExtension = dir.includes('python') ? 'py' : 'mjs';
|
|
const graderExtension = dir.includes('python') ? 'py' : 'mjs';
|
|
const expectedTargetId = dir.includes('python')
|
|
? `file://./target.${targetExtension}:call_api`
|
|
: `file://./target.${targetExtension}`;
|
|
expect(target.id).toBe(expectedTargetId);
|
|
if (dir === 'redteam-run-local-pass') {
|
|
expect(target.config).toEqual({ defaultUserId: 'qa-run-config' });
|
|
} else if (dir === 'redteam-run-local-python-pass') {
|
|
expect(target.config).toEqual({
|
|
workers: 1,
|
|
timeout: 30000,
|
|
defaultUserId: 'qa-py-run-config',
|
|
});
|
|
}
|
|
|
|
const redteam = config.redteam;
|
|
expectRecord(redteam, `${dir} redteam`);
|
|
const expectedGraderId = dir.includes('python')
|
|
? `file://test/fixtures/agent-skills/${dir}/grader.${graderExtension}:grade_redteam`
|
|
: `file://test/fixtures/agent-skills/${dir}/grader.${graderExtension}`;
|
|
expect(redteam.provider).toBe(expectedGraderId);
|
|
expect(redteam.maxConcurrency).toBe(1);
|
|
expect(Array.isArray(redteam.plugins), `${dir} redteam plugins`).toBe(true);
|
|
const redteamPlugins = redteam.plugins as unknown[];
|
|
expect(
|
|
redteamPlugins.map((plugin, index) => {
|
|
expectRecord(plugin, `${dir} redteam plugin ${index}`);
|
|
return plugin.id;
|
|
}),
|
|
).toEqual(['policy', 'rbac']);
|
|
|
|
const defaultTest = config.defaultTest;
|
|
expectRecord(defaultTest, `${dir} defaultTest`);
|
|
const defaultMetadata = defaultTest.metadata;
|
|
expectRecord(defaultMetadata, `${dir} defaultTest metadata`);
|
|
expect(typeof redteam.purpose).toBe('string');
|
|
expect(typeof defaultMetadata.purpose).toBe('string');
|
|
expect(defaultMetadata.purpose).toBe(redteam.purpose);
|
|
expect(String(defaultMetadata.purpose).length).toBeGreaterThan(90);
|
|
|
|
expect(Array.isArray(config.tests), `${dir} tests`).toBe(true);
|
|
const tests = config.tests as unknown[];
|
|
expect(tests).toHaveLength(2);
|
|
|
|
const pluginIds = tests.map((testCase, index) => {
|
|
expectRecord(testCase, `${dir} test ${index}`);
|
|
const metadata = testCase.metadata;
|
|
expectRecord(metadata, `${dir} test ${index} metadata`);
|
|
const assertions = testCase.assert;
|
|
expect(Array.isArray(assertions), `${dir} test ${index} assertions`).toBe(true);
|
|
expect(assertions).toHaveLength(1);
|
|
const [assertion] = assertions as unknown[];
|
|
expectRecord(assertion, `${dir} test ${index} assertion`);
|
|
|
|
expect(metadata.severity).toBe('high');
|
|
expect(assertion.type).toBe(`promptfoo:redteam:${metadata.pluginId}`);
|
|
expect(typeof assertion.metric).toBe('string');
|
|
expect(String(assertion.metric).length).toBeGreaterThan(8);
|
|
|
|
const vars = testCase.vars;
|
|
expectRecord(vars, `${dir} test ${index} vars`);
|
|
expect(typeof vars.prompt).toBe('string');
|
|
|
|
if (metadata.pluginId === 'policy') {
|
|
const pluginConfig = metadata.pluginConfig;
|
|
expectRecord(pluginConfig, `${dir} policy plugin config`);
|
|
expect(typeof pluginConfig.policy).toBe('string');
|
|
expect(pluginConfig.policy).toBe(metadata.policy);
|
|
} else {
|
|
expect(metadata.pluginConfig).toBeUndefined();
|
|
}
|
|
|
|
return metadata.pluginId;
|
|
});
|
|
|
|
expect(pluginIds).toEqual(['policy', 'rbac']);
|
|
});
|
|
}
|
|
|
|
it.each([
|
|
{
|
|
dir: 'redteam-run-local-pass',
|
|
snippets: [
|
|
'description: Redteam run local pass smoke',
|
|
'file://./target.mjs',
|
|
'defaultUserId: qa-run-config',
|
|
'file://test/fixtures/agent-skills/redteam-run-local-pass/grader.mjs',
|
|
'id: policy',
|
|
'id: rbac',
|
|
'promptfoo:redteam:policy',
|
|
'promptfoo:redteam:rbac',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-run-local-python-pass',
|
|
snippets: [
|
|
'description: Redteam run local Python pass smoke',
|
|
'file://./target.py:call_api',
|
|
'workers: 1',
|
|
'timeout: 30000',
|
|
'defaultUserId: qa-py-run-config',
|
|
'file://test/fixtures/agent-skills/redteam-run-local-python-pass/grader.py:grade_redteam',
|
|
'id: policy',
|
|
'id: rbac',
|
|
'promptfoo:redteam:policy',
|
|
'promptfoo:redteam:rbac',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-run-local-mixed',
|
|
snippets: [
|
|
'description: Redteam run local mixed smoke',
|
|
'file://./target.mjs',
|
|
'file://test/fixtures/agent-skills/redteam-run-local-mixed/grader.mjs',
|
|
'force-leak',
|
|
'id: policy',
|
|
'id: rbac',
|
|
],
|
|
},
|
|
{
|
|
dir: 'redteam-run-local-error',
|
|
snippets: [
|
|
'description: Redteam run local target error smoke',
|
|
'file://./target.mjs',
|
|
'file://test/fixtures/agent-skills/redteam-run-local-error/grader.mjs',
|
|
'target-error',
|
|
'id: policy',
|
|
'id: rbac',
|
|
],
|
|
},
|
|
])('ships a runnable/validatable $dir redteam run fixture', ({ dir, snippets }) => {
|
|
const config = readText(path.join(fixtureRoot, dir, 'redteam.yaml'));
|
|
const isPythonFixture = dir.includes('python');
|
|
const target = readText(
|
|
path.join(fixtureRoot, dir, isPythonFixture ? 'target.py' : 'target.mjs'),
|
|
);
|
|
const grader = readText(
|
|
path.join(fixtureRoot, dir, isPythonFixture ? 'grader.py' : 'grader.mjs'),
|
|
);
|
|
|
|
expect(config).toContain(
|
|
'# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json',
|
|
);
|
|
expect(config).toContain('defaultTest:');
|
|
expect(config).toContain('metadata:');
|
|
expect(config).toContain('pluginId:');
|
|
if (isPythonFixture) {
|
|
expect(target).toContain('def call_api(prompt: str, options: dict, context: dict)');
|
|
} else {
|
|
expect(target).toContain('export default class');
|
|
expect(target).toContain('async callApi');
|
|
}
|
|
if (dir === 'redteam-run-local-error') {
|
|
expect(target).toContain('Simulated target outage');
|
|
}
|
|
if (isPythonFixture) {
|
|
expect(grader).toContain('def grade_redteam(prompt: str, options: dict, context: dict)');
|
|
expect(grader).toContain('json.dumps');
|
|
} else {
|
|
expect(grader).toContain('export default class');
|
|
expect(grader).toContain('async callApi');
|
|
expect(grader).toContain('JSON.stringify');
|
|
}
|
|
expect(grader).toContain('pass');
|
|
expect(grader).toContain('score');
|
|
expect(grader).toContain('reason');
|
|
|
|
for (const snippet of snippets) {
|
|
expect(config).toContain(snippet);
|
|
}
|
|
});
|
|
});
|