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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { doGenerateRedteam } from '../../../src/redteam/commands/generate';
import { synthesize } from '../../../src/redteam/index';
import * as configModule from '../../../src/util/config/load';
import type { RedteamCliGenerateOptions } from '../../../src/redteam/types';
vi.mock('../../../src/redteam', async (importOriginal) => {
return {
...(await importOriginal()),
synthesize: vi.fn(),
};
});
vi.mock('../../../src/util/config/load', async (importOriginal) => {
return {
...(await importOriginal()),
resolveConfigs: vi.fn(),
};
});
vi.mock('../../../src/util/config/writer', async (importOriginal) => {
return {
...(await importOriginal()),
writePromptfooConfig: vi.fn(),
};
});
vi.mock('../../../src/providers', async (importOriginal) => {
return {
...(await importOriginal()),
loadApiProviders: vi.fn().mockResolvedValue([
{
id: () => 'openai:gpt-4',
callApi: vi.fn(),
cleanup: vi.fn(),
},
]),
getProviderIds: vi.fn().mockReturnValue(['openai:gpt-4']),
};
});
vi.mock('../../../src/logger', () => ({
__esModule: true,
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('../../../src/util/redteamProbeLimit', async (importOriginal) => {
return {
...(await importOriginal()),
checkRedteamProbeLimit: vi.fn().mockReturnValue({
withinLimit: true,
used: 0,
limit: 100_000,
remaining: 100_000,
}),
};
});
vi.mock('../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
neverGenerateRemote: vi.fn().mockReturnValue(true),
};
});
describe('cross-session-leak strategy exclusions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should include cross-session-leak plugin and pass through strategies', async () => {
vi.mocked(configModule.resolveConfigs).mockResolvedValue({
basePath: '/mock/path',
testSuite: {
prompts: [{ raw: 'Test prompt' }],
providers: [{ id: 'openai:gpt-4' }],
tests: [],
},
config: {
providers: ['openai:gpt-4'],
redteam: {
plugins: ['cross-session-leak'],
strategies: ['crescendo', 'goat', 'rot13'],
numTests: 1,
},
},
} as any);
vi.mocked(synthesize).mockResolvedValue({
testCases: [],
purpose: 'p',
entities: [],
injectVar: 'input',
failedPlugins: [],
});
const options: RedteamCliGenerateOptions = {
output: 'out.yaml',
cache: true,
defaultConfig: {},
write: true,
config: 'config.yaml',
} as any;
await doGenerateRedteam(options);
const synthOpts = vi.mocked(synthesize).mock.calls[0][0];
const plugin = synthOpts.plugins.find((p: any) => p.id === 'cross-session-leak');
expect(plugin).toBeDefined();
expect(synthOpts.strategies.map((s: any) => s.id)).toEqual(['crescendo', 'goat', 'rot13']);
});
});
+625
View File
@@ -0,0 +1,625 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ArgsSchema,
doTargetPurposeDiscovery,
normalizeTargetPurposeDiscoveryResult,
resolveDiscoveryProviderContext,
} from '../../../src/redteam/commands/discover';
import { fetchWithProxy } from '../../../src/util/fetch/index';
import { createMockProvider } from '../../factories/provider';
const { mockProgressBar, mockSingleBar } = vi.hoisted(() => ({
mockProgressBar: {
increment: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
},
mockSingleBar: vi.fn(),
}));
vi.mock('cli-progress', () => ({
default: {
SingleBar: mockSingleBar,
},
}));
vi.mock('../../../src/util/fetch/index');
const mockedFetchWithProxy = vi.mocked(fetchWithProxy);
describe('ArgsSchema', () => {
it('`config` and `target` are mutually exclusive', () => {
const args = {
config: 'test',
target: 'test',
preview: false,
overwrite: false,
};
const { success, error } = ArgsSchema.safeParse(args);
expect(success).toBe(false);
expect(error?.issues[0].message).toBe('Cannot specify both config and target!');
});
});
describe('resolveDiscoveryProviderContext', () => {
it('derives target context from a Cloud provider in a file reference', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'promptfoo-discover-'));
const providerPath = path.join(tempDir, 'targets.yaml');
try {
await fs.writeFile(providerPath, 'id: promptfoo://provider/cloud-target-123\n');
const result = resolveDiscoveryProviderContext(`file://${providerPath}`);
expect(result.cloudTargetId).toBe('cloud-target-123');
expect(result.providers).toEqual([{ id: 'promptfoo://provider/cloud-target-123' }]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('derives linked target context from a local provider in a file reference', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'promptfoo-discover-'));
const providerPath = path.join(tempDir, 'targets.yaml');
try {
await fs.writeFile(
providerPath,
[
'id: openai:gpt-4.1-mini',
'config:',
' linkedTargetId: promptfoo://provider/linked-target-123',
'',
].join('\n'),
);
const result = resolveDiscoveryProviderContext(`file://${providerPath}`);
expect(result.cloudTargetId).toBe('linked-target-123');
expect(result.providers).toEqual([
{
id: 'openai:gpt-4.1-mini',
config: { linkedTargetId: 'promptfoo://provider/linked-target-123' },
},
]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
describe('normalizeTargetPurposeDiscoveryResult', () => {
it('should handle null-like values', () => {
const result = normalizeTargetPurposeDiscoveryResult({
purpose: null,
limitations: '',
user: 'null',
tools: [],
});
expect(result).toEqual({
purpose: null,
limitations: null,
user: null,
tools: [],
});
});
it('should handle string "null" values', () => {
const result = normalizeTargetPurposeDiscoveryResult({
purpose: 'null',
limitations: 'null',
user: 'null',
tools: [],
});
expect(result).toEqual({
purpose: null,
limitations: null,
user: null,
tools: [],
});
});
it('should handle invalid tools array', () => {
const result = normalizeTargetPurposeDiscoveryResult({
purpose: 'test',
limitations: 'test',
user: 'test',
tools: null as any,
});
expect(result).toEqual({
purpose: 'test',
limitations: 'test',
user: 'test',
tools: [],
});
});
it('should filter invalid tools from array', () => {
const result = normalizeTargetPurposeDiscoveryResult({
purpose: 'test',
limitations: 'test',
user: 'test',
tools: [
null,
{ name: 'tool1', description: 'desc1', arguments: [] },
null,
{ name: 'tool2', description: 'desc2', arguments: [] },
],
});
expect(result).toEqual({
purpose: 'test',
limitations: 'test',
user: 'test',
tools: [
{ name: 'tool1', description: 'desc1', arguments: [] },
{ name: 'tool2', description: 'desc2', arguments: [] },
],
});
});
});
describe('doTargetPurposeDiscovery', () => {
beforeEach(() => {
mockSingleBar.mockImplementation(function () {
return mockProgressBar;
});
});
afterEach(() => {
vi.resetAllMocks();
});
it('should handle empty prompt', async () => {
const mockResponses = [
{
done: false,
question: 'What is your purpose?',
state: {
currentQuestionIndex: 0,
answers: [],
},
},
{
done: true,
purpose: {
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [
{
name: 'tool1',
description: 'desc1',
arguments: [{ name: 'a', description: 'd', type: 'string' }],
},
],
user: 'Test user',
},
state: {
currentQuestionIndex: 1,
answers: ['I am a test assistant'],
},
},
];
mockedFetchWithProxy.mockImplementation(function () {
return Promise.resolve(
new Response(JSON.stringify(mockResponses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
});
const target = createMockProvider({
id: 'test',
response: { output: 'I am a test assistant' },
});
const discoveredPurpose = await doTargetPurposeDiscovery(target);
expect(target.callApi).toHaveBeenCalledWith('What is your purpose?', {
prompt: { raw: 'What is your purpose?', label: 'Target Discovery Question' },
vars: { sessionId: expect.any(String) },
bustCache: true,
});
expect(mockSingleBar).toHaveBeenCalledWith(
expect.objectContaining({
format: 'Mapping the target {bar} {percentage}% | {value}/{total} turns',
}),
);
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(2);
expect(mockProgressBar.start).toHaveBeenCalledWith(5, 0);
expect(mockProgressBar.increment).toHaveBeenCalledTimes(2);
expect(mockProgressBar.stop).toHaveBeenCalledOnce();
expect(discoveredPurpose).toEqual({
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [
{
name: 'tool1',
description: 'desc1',
arguments: [{ name: 'a', description: 'd', type: 'string' }],
},
],
user: 'Test user',
});
});
it('should include Cloud target context in discovery requests', async () => {
mockedFetchWithProxy.mockResolvedValue(
new Response(
JSON.stringify({
done: true,
purpose: {
purpose: 'Test purpose',
limitations: null,
tools: [],
user: null,
},
state: {
currentQuestionIndex: 0,
answers: [],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const target = createMockProvider({ id: 'test', response: { output: 'unused' } });
await doTargetPurposeDiscovery(target, undefined, false, 'cloud-target-123');
const request = mockedFetchWithProxy.mock.calls[0]?.[1];
expect(request).toBeDefined();
expect(JSON.parse(request!.body as string)).toMatchObject({
task: 'target-purpose-discovery',
targetId: 'cloud-target-123',
});
});
it('should stop when the maximum turn count is reached', async () => {
mockedFetchWithProxy.mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
done: false,
question: 'What else should I know?',
state: {
currentQuestionIndex: 0,
answers: [],
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
const target = createMockProvider({
id: 'test',
response: { output: 'Another answer' },
});
await expect(doTargetPurposeDiscovery(target)).rejects.toThrow('Too many retries, giving up.');
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(10);
expect(target.callApi).toHaveBeenCalledTimes(9);
expect(mockProgressBar.start).toHaveBeenCalledWith(5, 0);
expect(mockProgressBar.increment).toHaveBeenCalledTimes(10);
expect(mockProgressBar.stop).toHaveBeenCalledOnce();
expect(mockProgressBar.increment.mock.invocationCallOrder.at(-1)!).toBeLessThan(
mockProgressBar.stop.mock.invocationCallOrder[0]!,
);
});
it('delegates auth to the fetch layer and never sends an Authorization header itself', async () => {
// Single done=true turn so exactly one remote-generation request is made.
mockedFetchWithProxy.mockResolvedValue(
new Response(
JSON.stringify({
done: true,
purpose: {
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [],
user: 'Test user',
},
state: { currentQuestionIndex: 0, answers: [] },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const target = createMockProvider({ id: 'test', response: { output: 'ok' } });
await doTargetPurposeDiscovery(target);
// discover.ts must not attach the cloud token itself: the centralized fetch layer
// injects it only for the configured cloud origin, so a custom
// PROMPTFOO_REMOTE_GENERATION_URL can never receive the saved credential.
expect(mockedFetchWithProxy).toHaveBeenCalled();
for (const call of mockedFetchWithProxy.mock.calls) {
const headers = (call[1]?.headers ?? {}) as Record<string, string>;
const headerKeys = Object.keys(headers).map((key) => key.toLowerCase());
expect(headerKeys).not.toContain('authorization');
}
});
it('should render the prompt if passed in', async () => {
const mockResponses = [
{
done: false,
question: 'What is your purpose?',
state: {
currentQuestionIndex: 0,
answers: [],
},
},
{
done: true,
purpose: {
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [
{
name: 'tool1',
description: 'desc1',
arguments: [{ name: 'a', description: 'd', type: 'string' }],
},
],
user: 'Test user',
},
state: {
currentQuestionIndex: 1,
answers: ['I am a test assistant'],
},
},
];
mockedFetchWithProxy.mockImplementation(function () {
return Promise.resolve(
new Response(JSON.stringify(mockResponses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
});
const target = createMockProvider({
id: 'test',
response: { output: 'I am a test assistant' },
});
const prompt = {
raw: 'This is a test prompt {{prompt}}',
label: 'Test Prompt',
};
const discoveredPurpose = await doTargetPurposeDiscovery(target, prompt);
expect(target.callApi).toHaveBeenCalledWith('This is a test prompt What is your purpose?', {
prompt: { raw: 'What is your purpose?', label: 'Target Discovery Question' },
vars: { sessionId: expect.any(String) },
bustCache: true,
});
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(2);
expect(discoveredPurpose).toEqual({
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [
{
name: 'tool1',
description: 'desc1',
arguments: [{ name: 'a', description: 'd', type: 'string' }],
},
],
user: 'Test user',
});
});
it('should normalize string "null" values from server response', async () => {
const mockResponses = [
{
done: false,
question: 'What is your purpose?',
state: {
currentQuestionIndex: 0,
answers: [],
},
},
{
done: true,
purpose: {
purpose: 'null',
limitations: 'null',
tools: [],
user: 'null',
},
state: {
currentQuestionIndex: 1,
answers: ['I cannot provide that information'],
},
},
];
mockedFetchWithProxy.mockImplementation(function () {
return Promise.resolve(
new Response(JSON.stringify(mockResponses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
});
const target = {
id: () => 'test',
callApi: vi.fn().mockResolvedValue({ output: 'I cannot provide that information' }),
};
const discoveredPurpose = await doTargetPurposeDiscovery(target);
expect(discoveredPurpose).toEqual({
purpose: null,
limitations: null,
tools: [],
user: null,
});
});
it('should throw immediately on non-OK HTTP response with an actionable hint', async () => {
const errorBody = JSON.stringify({
error: 'Invalid task',
message: 'Unknown task: target-purpose-discovery',
});
mockedFetchWithProxy.mockResolvedValue(
new Response(errorBody, {
status: 400,
headers: { 'Content-Type': 'application/json' },
}),
);
const target = {
id: () => 'test',
callApi: vi.fn(),
};
const error = await doTargetPurposeDiscovery(target).catch((e) => e);
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(
'Remote server returned HTTP 400: Unknown task: target-purpose-discovery',
);
expect(error.message).toContain('promptfoo@latest');
// Should not retry — fetch should only be called once
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(1);
expect(target.callApi).not.toHaveBeenCalled();
expect(mockProgressBar.increment).toHaveBeenCalledOnce();
expect(mockProgressBar.stop).toHaveBeenCalledOnce();
});
it('should surface an auth hint on 401 responses', async () => {
mockedFetchWithProxy.mockResolvedValue(
new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
);
const target = {
id: () => 'test',
callApi: vi.fn(),
};
const error = await doTargetPurposeDiscovery(target, undefined, false).catch((e) => e);
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain('Remote server returned HTTP 401: Unauthorized');
expect(error.message).toContain('promptfoo auth login');
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(1);
expect(target.callApi).not.toHaveBeenCalled();
});
it('should throw with raw text on non-OK response with non-JSON body', async () => {
mockedFetchWithProxy.mockResolvedValue(
new Response('Service Unavailable', {
status: 503,
headers: { 'Content-Type': 'text/plain' },
}),
);
const target = {
id: () => 'test',
callApi: vi.fn(),
};
await expect(doTargetPurposeDiscovery(target, undefined, false)).rejects.toThrow(
'Remote server returned HTTP 503: Service Unavailable',
);
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(1);
expect(target.callApi).not.toHaveBeenCalled();
});
it('should fall back to status text on non-OK response with empty body', async () => {
mockedFetchWithProxy.mockResolvedValue(
new Response('', {
status: 418,
statusText: "I'm a teapot",
}),
);
const target = {
id: () => 'test',
callApi: vi.fn(),
};
await expect(doTargetPurposeDiscovery(target, undefined, false)).rejects.toThrow(
"Remote server returned HTTP 418: I'm a teapot",
);
expect(mockedFetchWithProxy).toHaveBeenCalledTimes(1);
expect(target.callApi).not.toHaveBeenCalled();
});
it('should handle mixed valid/invalid tools from server', async () => {
const mockResponses = [
{
done: false,
question: 'What is your purpose?',
state: {
currentQuestionIndex: 0,
answers: [],
},
},
{
done: true,
purpose: {
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [
null,
{ name: 'tool1', description: 'desc1', arguments: [] },
null,
{ name: 'tool2', description: 'desc2', arguments: [] },
],
user: 'Test user',
},
state: {
currentQuestionIndex: 1,
answers: ['Test response'],
},
},
];
mockedFetchWithProxy.mockImplementation(function () {
return Promise.resolve(
new Response(JSON.stringify(mockResponses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
});
const target = {
id: () => 'test',
callApi: vi.fn().mockResolvedValue({ output: 'Test response' }),
};
const discoveredPurpose = await doTargetPurposeDiscovery(target);
expect(discoveredPurpose).toEqual({
purpose: 'Test purpose',
limitations: 'Test limitations',
tools: [
{ name: 'tool1', description: 'desc1', arguments: [] },
{ name: 'tool2', description: 'desc2', arguments: [] },
],
user: 'Test user',
});
});
});
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
import fs from 'fs/promises';
import confirm from '@inquirer/confirm';
import editor from '@inquirer/editor';
import input from '@inquirer/input';
import select from '@inquirer/select';
import * as yaml from 'js-yaml';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readGlobalConfig } from '../../../src/globalConfig/globalConfig';
import { doGenerateRedteam } from '../../../src/redteam/commands/generate';
import { redteamInit, renderRedteamConfig } from '../../../src/redteam/commands/init';
import { type Strategy } from '../../../src/redteam/constants';
import { ProbeLimitExceededError } from '../../../src/redteam/types';
import type { RedteamFileConfig } from '../../../src/redteam/types';
vi.mock('fs/promises');
vi.mock('@inquirer/confirm');
vi.mock('@inquirer/editor');
vi.mock('@inquirer/input');
vi.mock('@inquirer/select');
vi.mock('../../../src/globalConfig/accounts', () => ({
getUserEmail: vi.fn(),
setUserEmail: vi.fn(),
}));
vi.mock('../../../src/globalConfig/globalConfig', () => ({
readGlobalConfig: vi.fn(),
writeGlobalConfigPartial: vi.fn(),
}));
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('../../../src/redteam/commands/generate', () => ({
doGenerateRedteam: vi.fn(),
}));
vi.mock('../../../src/telemetry', () => ({
default: {
record: vi.fn(),
saveConsent: vi.fn(),
},
}));
describe('renderRedteamConfig', () => {
it('should generate valid YAML that conforms to RedteamFileConfig', () => {
const input = {
purpose: 'Test chatbot security',
numTests: 5,
plugins: [{ id: 'rbac', numTests: 2 }],
strategies: ['prompt-injection'] as Strategy[],
prompts: ['Hello {{prompt}}'],
providers: ['openai:gpt-4'],
descriptions: {
'math-prompt': 'Basic prompt injection test',
rbac: 'Basic RBAC test',
},
};
const renderedConfig = renderRedteamConfig(input);
expect(renderedConfig).toBeDefined();
const parsedConfig = yaml.load(renderedConfig) as {
description: string;
prompts: string[];
targets: string[];
redteam: RedteamFileConfig;
};
expect(parsedConfig.redteam.purpose).toBe(input.purpose);
expect(parsedConfig.redteam.numTests).toBe(input.numTests);
expect(parsedConfig.redteam.plugins).toHaveLength(1);
expect(parsedConfig.redteam.plugins?.[0]).toMatchObject({
id: 'rbac',
numTests: 2,
});
});
it('should handle minimal configuration', () => {
const input = {
purpose: 'Basic test',
numTests: 1,
plugins: [],
strategies: [],
prompts: [],
providers: [],
descriptions: {},
};
const renderedConfig = renderRedteamConfig(input);
const parsedConfig = yaml.load(renderedConfig) as {
redteam: RedteamFileConfig;
};
expect(parsedConfig.redteam.purpose).toBe(input.purpose);
expect(parsedConfig.redteam.numTests).toBe(input.numTests);
expect(parsedConfig.redteam.plugins || []).toEqual([]);
expect(parsedConfig.redteam.strategies || []).toEqual([]);
});
it('should include all provided plugins and strategies', () => {
const input = {
purpose: 'Test all plugins',
numTests: 3,
plugins: [
{ id: 'prompt-injection', numTests: 1 },
{ id: 'policy', numTests: 2 },
],
strategies: ['basic', 'jailbreak'] as Strategy[],
prompts: ['Test {{prompt}}'],
providers: ['openai:gpt-4'],
descriptions: {
'basic-injection': 'Basic test',
'advanced-injection': 'Advanced test',
},
};
const renderedConfig = renderRedteamConfig(input);
const parsedConfig = yaml.load(renderedConfig) as {
redteam: RedteamFileConfig;
};
expect(parsedConfig.redteam.plugins).toHaveLength(2);
expect(parsedConfig.redteam.strategies).toHaveLength(2);
expect(parsedConfig.redteam.plugins).toEqual(
expect.arrayContaining([
{ id: 'prompt-injection', numTests: 1 },
{ id: 'policy', numTests: 2 },
]),
);
expect(parsedConfig.redteam.strategies).toEqual(expect.arrayContaining(['basic', 'jailbreak']));
});
it('should handle custom provider configuration', () => {
const input = {
purpose: 'Test custom provider',
numTests: 1,
plugins: [],
strategies: [],
prompts: ['Test'],
providers: [
{
id: 'custom-provider',
label: 'Custom API',
config: {
apiKey: '{{CUSTOM_API_KEY}}',
baseUrl: 'https://api.custom.com',
},
},
],
descriptions: {},
};
const renderedConfig = renderRedteamConfig(input);
const parsedConfig = yaml.load(renderedConfig) as {
targets: Array<{
id: string;
label: string;
config: Record<string, string>;
}>;
};
expect(parsedConfig.targets).toBeDefined();
expect(parsedConfig.targets[0]).toMatchObject({
id: 'custom-provider',
label: 'Custom API',
config: {
apiKey: '{{CUSTOM_API_KEY}}',
baseUrl: 'https://api.custom.com',
},
});
});
});
describe('redteamInit', () => {
let originalExitCode: number | string | null | undefined;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'clear').mockImplementation(() => {});
originalExitCode = process.exitCode;
process.exitCode = 0;
vi.mocked(input).mockResolvedValue('target');
vi.mocked(select)
.mockResolvedValueOnce('prompt_model_chatbot')
.mockResolvedValueOnce('now')
.mockResolvedValueOnce('openai:gpt-5-mini')
.mockResolvedValueOnce('default')
.mockResolvedValueOnce('default');
vi.mocked(editor).mockResolvedValue('User query: {{prompt}}');
vi.mocked(confirm).mockResolvedValue(true);
vi.mocked(readGlobalConfig).mockReturnValue({ hasHarmfulRedteamConsent: true } as any);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
afterEach(() => {
process.exitCode = originalExitCode;
vi.restoreAllMocks();
});
it('should treat probe-limit exhaustion as an expected CLI failure', async () => {
vi.mocked(doGenerateRedteam).mockRejectedValueOnce(new ProbeLimitExceededError(100, 100));
await expect(redteamInit(undefined)).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
});
});
+448
View File
@@ -0,0 +1,448 @@
import fs from 'fs';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getUserEmail } from '../../../src/globalConfig/accounts';
import {
doPoisonDocuments,
generatePoisonedDocument,
getAllFiles,
poisonDocument,
} from '../../../src/redteam/commands/poison';
import { getRemoteGenerationUrl } from '../../../src/redteam/remoteGeneration';
import { mockProcessEnv } from '../../util/utils';
const mockFsReadFileSync = vi.hoisted(() => vi.fn());
const mockFsExistsSync = vi.hoisted(() => vi.fn());
const mockFsStatSync = vi.hoisted(() => vi.fn());
const mockFsMkdirSync = vi.hoisted(() => vi.fn());
const mockFsWriteFileSync = vi.hoisted(() => vi.fn());
const mockFsReaddirSync = vi.hoisted(() => vi.fn());
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
readFileSync: mockFsReadFileSync,
existsSync: mockFsExistsSync,
statSync: mockFsStatSync,
mkdirSync: mockFsMkdirSync,
writeFileSync: mockFsWriteFileSync,
readdirSync: mockFsReaddirSync,
},
readFileSync: mockFsReadFileSync,
existsSync: mockFsExistsSync,
statSync: mockFsStatSync,
mkdirSync: mockFsMkdirSync,
writeFileSync: mockFsWriteFileSync,
readdirSync: mockFsReaddirSync,
};
});
vi.mock('fs/promises', () => ({
default: {
readFile: mockFsReadFileSync,
mkdir: mockFsMkdirSync,
writeFile: mockFsWriteFileSync,
stat: vi.fn(async (...args: unknown[]) => mockFsStatSync(...args)),
},
readFile: mockFsReadFileSync,
mkdir: mockFsMkdirSync,
writeFile: mockFsWriteFileSync,
stat: vi.fn(async (...args: unknown[]) => mockFsStatSync(...args)),
}));
const mockPathJoin = vi.hoisted(() => vi.fn());
const mockPathRelative = vi.hoisted(() => vi.fn());
const mockPathResolve = vi.hoisted(() => vi.fn());
const mockPathDirname = vi.hoisted(() => vi.fn().mockReturnValue('mock-dir'));
vi.mock('path', async () => ({
...(await vi.importActual('path')),
default: {
...(await vi.importActual<typeof import('path')>('path')),
resolve: mockPathResolve,
join: mockPathJoin,
relative: mockPathRelative,
dirname: mockPathDirname,
},
resolve: mockPathResolve,
join: mockPathJoin,
relative: mockPathRelative,
dirname: mockPathDirname,
}));
describe('poison command', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFsReadFileSync.mockReset();
mockFsExistsSync.mockReset();
mockFsStatSync.mockReset();
mockFsMkdirSync.mockReset();
mockFsWriteFileSync.mockReset();
mockFsReaddirSync.mockReset();
mockPathJoin.mockReset();
mockPathRelative.mockReset();
mockPathResolve.mockReset();
mockPathDirname.mockReset();
// Set default implementations for fs mocks
mockFsMkdirSync.mockImplementation(() => '/mock/dir');
mockFsWriteFileSync.mockReturnValue(undefined);
mockFsReadFileSync.mockReturnValue('test content');
mockFsExistsSync.mockReturnValue(true);
mockPathDirname.mockReturnValue('mock-dir');
});
describe('getAllFiles', () => {
it('should get all files recursively', () => {
const mockFiles = ['file1.txt', 'file2.txt'];
const mockDirs = ['dir1'];
vi.mocked(fs.readdirSync).mockImplementationOnce(function () {
return [...mockFiles, ...mockDirs] as any;
});
vi.mocked(fs.readdirSync).mockImplementationOnce(function () {
return [] as any;
});
vi.mocked(fs.statSync).mockImplementation(function (filePath) {
return {
isDirectory: () => filePath.toString().includes('dir1'),
} as fs.Stats;
});
vi.mocked(path.join).mockImplementation(function (...parts) {
return parts.join('/');
});
const files = getAllFiles('testDir');
expect(files).toEqual(['testDir/file1.txt', 'testDir/file2.txt']);
});
});
describe('generatePoisonedDocument', () => {
it.each([
'PROMPTFOO_DISABLE_REMOTE_GENERATION',
'PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION',
])('should not send document contents when %s is enabled', async (flag) => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_DISABLE_REMOTE_GENERATION: undefined,
PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION: undefined,
[flag]: 'true',
});
const fetchSpy = vi.spyOn(global, 'fetch');
try {
await expect(generatePoisonedDocument('sensitive document')).rejects.toThrow(
'RAG poisoning requires remote generation, which has been explicitly disabled',
);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
restoreEnv();
}
});
it('should call remote API and return response', async () => {
const mockResponse = {
ok: true,
json: () =>
Promise.resolve({
poisonedDocument: 'poisoned content',
intendedResult: 'result',
task: 'poison-document',
}),
headers: new Headers(),
redirected: false,
status: 200,
statusText: 'OK',
type: 'basic',
url: 'test-url',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const result = await generatePoisonedDocument('test doc', 'test goal');
expect(fetch).toHaveBeenCalledWith(
getRemoteGenerationUrl(),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
body: JSON.stringify({
task: 'poison-document',
document: 'test doc',
goal: 'test goal',
email: getUserEmail(),
}),
}),
);
expect(result).toEqual({
poisonedDocument: 'poisoned content',
intendedResult: 'result',
task: 'poison-document',
});
});
it('should throw error on API failure', async () => {
const mockResponse = {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve('API Error'),
headers: new Headers(),
redirected: false,
type: 'basic',
url: 'test-url',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await expect(generatePoisonedDocument('test doc')).rejects.toThrow(
'Failed to generate poisoned document',
);
});
});
describe('poisonDocument', () => {
it('should poison file document', async () => {
const mockDoc = {
docLike: 'test.txt',
isFile: true,
dir: null,
};
vi.mocked(fs.readFileSync).mockImplementation(function () {
return 'test content';
});
vi.mocked(path.relative).mockImplementation(function (_from, _to) {
return 'test.txt';
});
vi.mocked(path.dirname).mockImplementation(function () {
return 'output-dir';
});
vi.mocked(path.join).mockImplementation(function (...args) {
return args.join('/');
});
const mockPoisonResponse = {
poisonedDocument: 'poisoned content',
intendedResult: 'result',
task: 'poison-document',
};
const mockResponse = {
ok: true,
json: () => Promise.resolve(mockPoisonResponse),
headers: new Headers(),
redirected: false,
status: 200,
statusText: 'OK',
type: 'basic',
url: 'test-url',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const result = await poisonDocument(mockDoc, 'output-dir');
expect(result).toEqual({
originalPath: 'test.txt',
poisonedDocument: 'poisoned content',
intendedResult: 'result',
});
});
it('should poison content document', async () => {
const mockDoc = {
docLike: 'test content',
isFile: false,
dir: null,
};
const mockPoisonResponse = {
poisonedDocument: 'poisoned content',
intendedResult: 'result',
task: 'poison-document',
};
const mockResponse = {
ok: true,
json: () => Promise.resolve(mockPoisonResponse),
headers: new Headers(),
redirected: false,
status: 200,
statusText: 'OK',
type: 'basic',
url: 'test-url',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const result = await poisonDocument(mockDoc, 'output-dir');
expect(result).toEqual({
poisonedDocument: 'poisoned content',
intendedResult: 'result',
});
});
it('should throw error when poisoning fails', async () => {
const mockDoc = {
docLike: 'test.txt',
isFile: true,
dir: null,
};
vi.mocked(fs.readFileSync).mockImplementation(function () {
throw new Error('File read error');
});
await expect(poisonDocument(mockDoc, 'output-dir')).rejects.toThrow(
'Failed to poison test.txt: Error: File read error',
);
});
});
describe('doPoisonDocuments', () => {
it.each([
'PROMPTFOO_DISABLE_REMOTE_GENERATION',
'PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION',
])('should fail before touching the filesystem when %s is enabled', async (flag) => {
const restoreEnv = mockProcessEnv({
PROMPTFOO_DISABLE_REMOTE_GENERATION: undefined,
PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION: undefined,
[flag]: 'true',
});
const fetchSpy = vi.spyOn(global, 'fetch');
try {
await expect(doPoisonDocuments({ documents: ['sensitive document'] })).rejects.toThrow(
'RAG poisoning requires remote generation, which has been explicitly disabled',
);
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(fs.writeFileSync).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
restoreEnv();
}
});
it('should process multiple documents', async () => {
const options = {
documents: ['test.txt', 'test content'],
goal: 'test goal',
output: 'output.yaml',
outputDir: 'output-dir',
};
vi.mocked(fs.existsSync).mockImplementation(function (path) {
return path === 'test.txt';
});
vi.mocked(fs.statSync).mockImplementation(function () {
return {
isDirectory: () => false,
} as fs.Stats;
});
vi.mocked(path.relative).mockImplementation(function (_from, _to) {
return 'test.txt';
});
vi.mocked(path.dirname).mockImplementation(function () {
return 'output-dir';
});
vi.mocked(path.join).mockImplementation(function (...args) {
return args.join('/');
});
const mockPoisonResponse = {
poisonedDocument: 'poisoned content',
intendedResult: 'result',
task: 'poison-document',
};
const mockResponse = {
ok: true,
json: () => Promise.resolve(mockPoisonResponse),
headers: new Headers(),
redirected: false,
status: 200,
statusText: 'OK',
type: 'basic',
url: 'test-url',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await doPoisonDocuments(options);
expect(fs.mkdirSync).toHaveBeenCalledWith('output-dir', { recursive: true });
expect(fs.writeFileSync).toHaveBeenCalledWith('output.yaml', expect.any(String));
});
it('should handle directory input', async () => {
const options = {
documents: ['test-dir'],
goal: 'test goal',
output: 'output.yaml',
outputDir: 'output-dir',
};
vi.mocked(fs.existsSync).mockImplementation(function () {
return true;
});
vi.mocked(fs.statSync)
.mockImplementationOnce(function () {
return {
isDirectory: () => true,
} as fs.Stats;
})
.mockReturnValue({
isDirectory: () => false,
} as fs.Stats);
vi.mocked(fs.readdirSync).mockImplementationOnce(function () {
return ['file1.txt'] as any;
});
vi.mocked(path.join).mockImplementation(function (...parts) {
return parts.join('/');
});
vi.mocked(path.relative).mockImplementation(function (_from, _to) {
return 'file1.txt';
});
vi.mocked(path.dirname).mockImplementation(function () {
return 'output-dir';
});
const mockPoisonResponse = {
poisonedDocument: 'poisoned content',
intendedResult: 'result',
task: 'poison-document',
};
const mockResponse = {
ok: true,
json: () => Promise.resolve(mockPoisonResponse),
headers: new Headers(),
redirected: false,
status: 200,
statusText: 'OK',
type: 'basic',
url: 'test-url',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await doPoisonDocuments(options);
expect(fs.mkdirSync).toHaveBeenCalledWith('output-dir', { recursive: true });
expect(fs.writeFileSync).toHaveBeenCalledWith('output.yaml', expect.any(String));
});
});
});
+441
View File
@@ -0,0 +1,441 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EmailValidationError } from '../../../src/globalConfig/accounts';
import logger from '../../../src/logger';
import { redteamRunCommand } from '../../../src/redteam/commands/run';
import { doRedteamRun } from '../../../src/redteam/shared';
import { ProbeLimitExceededError } from '../../../src/redteam/types';
import { getConfigFromCloud } from '../../../src/util/cloud';
vi.mock('../../../src/cliState', () => ({
default: {
remote: false,
},
}));
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
getLogLevel: vi.fn().mockReturnValue('info'),
}));
vi.mock('../../../src/telemetry', () => ({
default: {
record: vi.fn(),
},
}));
vi.mock('../../../src/util', async (importOriginal) => {
return {
...(await importOriginal()),
setupEnv: vi.fn(),
};
});
vi.mock('../../../src/redteam/shared', () => ({
doRedteamRun: vi.fn().mockResolvedValue({}),
}));
vi.mock('../../../src/util/cloud', async (importOriginal) => {
return {
...(await importOriginal()),
getConfigFromCloud: vi.fn(),
};
});
describe('redteamRunCommand', () => {
let program: Command;
let originalExitCode: number | undefined;
beforeEach(() => {
vi.resetAllMocks();
program = new Command();
redteamRunCommand(program);
// Store original exitCode to restore later
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
});
afterEach(() => {
// Restore original exitCode
process.exitCode = originalExitCode;
});
it('should use target option to set cloud target when UUID is provided', async () => {
// Mock the getConfigFromCloud function
const mockConfig = {
prompts: ['Test prompt'],
vars: {},
providers: [{ id: 'test-provider' }],
targets: [
{
id: 'test-provider',
},
],
};
vi.mocked(getConfigFromCloud).mockResolvedValue(mockConfig);
// UUID format for config and target
const configUUID = '12345678-1234-1234-1234-123456789012';
const targetUUID = '87654321-4321-4321-4321-210987654321';
// Find the run command
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
// Execute the command with the target option
await runCommand!.parseAsync(['node', 'test', '--config', configUUID, '--target', targetUUID]);
// Verify doRedteamRun was called with the right parameters
expect(doRedteamRun).toHaveBeenCalledWith(
expect.objectContaining({
liveRedteamConfig: mockConfig,
config: undefined,
loadedFromCloud: true,
target: targetUUID,
eventSource: 'cli',
}),
);
});
it('should not support target argument with a local config file', async () => {
// Find the run command
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
const configPath = 'path/to/config.yaml';
const targetUUID = '87654321-4321-4321-4321-210987654321';
// Execute the command with the target option but a path config
await runCommand!.parseAsync(['node', 'test', '--config', configPath, '--target', targetUUID]);
// Should log error message
expect(logger.error).toHaveBeenCalledWith(
`Target ID (-t) can only be used when -c is used. To use a cloud target inside of a config set the id of the target to promptfoo://provider/${targetUUID}. `,
);
// Should set exit code to 1
expect(process.exitCode).toBe(1);
// getConfigFromCloud should not be called
expect(getConfigFromCloud).not.toHaveBeenCalled();
// doRedteamRun should not be called
expect(doRedteamRun).not.toHaveBeenCalled();
});
it('should not support target argument when no cloud config file is provided', async () => {
// Find the run command
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
const targetUUID = '87654321-4321-4321-4321-210987654321';
// Execute the command with the target option but a path config
await runCommand!.parseAsync(['node', 'test', '--target', targetUUID]);
// Should log error message
expect(logger.error).toHaveBeenCalledWith(
`Target ID (-t) can only be used when -c is used. To use a cloud target inside of a config set the id of the target to promptfoo://provider/${targetUUID}. `,
);
// Should set exit code to 1
expect(process.exitCode).toBe(1);
// getConfigFromCloud should not be called
expect(getConfigFromCloud).not.toHaveBeenCalled();
// doRedteamRun should not be called
expect(doRedteamRun).not.toHaveBeenCalled();
});
it('should not log an unexpected error for recoverable email validation failures', async () => {
vi.mocked(doRedteamRun).mockRejectedValueOnce(
new EmailValidationError(
'email_verification_required',
'Please verify your email address and try again.',
),
);
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync(['node', 'test']);
expect(process.exitCode).toBe(1);
expect(logger.error).not.toHaveBeenCalled();
});
it('should throw error when target is not a UUID', async () => {
// UUID format for config but not for target
const configUUID = '12345678-1234-1234-1234-123456789012';
const invalidTarget = 'not-a-uuid';
// Find the run command
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
// Execute the command with the target option and expect it to throw
await expect(
runCommand!.parseAsync(['node', 'test', '--config', configUUID, '--target', invalidTarget]),
).rejects.toThrow('Invalid target ID, it must be a valid UUID');
// Verify getConfigFromCloud was not called
expect(getConfigFromCloud).not.toHaveBeenCalled();
});
it('should handle backwards compatibility with empty targets and a valid target UUID', async () => {
// Mock the getConfigFromCloud function to return config without targets
const mockConfig = {
prompts: ['Test prompt'],
vars: {},
providers: [{ id: 'test-provider' }],
targets: [], // Empty targets
};
vi.mocked(getConfigFromCloud).mockResolvedValue(mockConfig);
// UUID format for config and target
const configUUID = '12345678-1234-1234-1234-123456789012';
const targetUUID = '87654321-4321-4321-4321-210987654321';
// Find the run command
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
// Execute the command with the target option
await runCommand!.parseAsync(['node', 'test', '--config', configUUID, '--target', targetUUID]);
// Verify that a target was added to the config with the CLOUD_PROVIDER_PREFIX
expect(mockConfig.targets).toEqual([
{
id: `promptfoo://provider/${targetUUID}`,
config: {},
},
]);
// Verify doRedteamRun was called with the updated config
expect(doRedteamRun).toHaveBeenCalledWith(
expect.objectContaining({
liveRedteamConfig: mockConfig,
config: undefined,
loadedFromCloud: true,
target: targetUUID,
eventSource: 'cli',
}),
);
});
describe('--description flag for custom scan names', () => {
it('should override config description when --description flag is provided', async () => {
const mockConfig = {
description: 'Original Cloud Description',
prompts: ['Test prompt'],
vars: {},
providers: [{ id: 'test-provider' }],
targets: [{ id: 'test-provider' }],
};
vi.mocked(getConfigFromCloud).mockResolvedValue(mockConfig);
const configUUID = '12345678-1234-1234-1234-123456789012';
const targetUUID = '87654321-4321-4321-4321-210987654321';
const customDescription = 'My Custom Scan Name';
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync([
'node',
'test',
'--config',
configUUID,
'--target',
targetUUID,
'--description',
customDescription,
]);
// Verify that the description was overridden
expect(mockConfig.description).toBe(customDescription);
// Verify doRedteamRun was called with the updated config
expect(doRedteamRun).toHaveBeenCalledWith(
expect.objectContaining({
liveRedteamConfig: expect.objectContaining({
description: customDescription,
}),
eventSource: 'cli',
}),
);
});
it('should keep original description when --description flag is not provided', async () => {
const originalDescription = 'Original Cloud Description';
const mockConfig = {
description: originalDescription,
prompts: ['Test prompt'],
vars: {},
providers: [{ id: 'test-provider' }],
targets: [{ id: 'test-provider' }],
};
vi.mocked(getConfigFromCloud).mockResolvedValue(mockConfig);
const configUUID = '12345678-1234-1234-1234-123456789012';
const targetUUID = '87654321-4321-4321-4321-210987654321';
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync([
'node',
'test',
'--config',
configUUID,
'--target',
targetUUID,
]);
// Verify that the description was not changed
expect(mockConfig.description).toBe(originalDescription);
// Verify doRedteamRun was called with the original description
expect(doRedteamRun).toHaveBeenCalledWith(
expect.objectContaining({
liveRedteamConfig: expect.objectContaining({
description: originalDescription,
}),
eventSource: 'cli',
}),
);
});
it('should support short -d flag for description', async () => {
const mockConfig = {
description: 'Original Cloud Description',
prompts: ['Test prompt'],
vars: {},
providers: [{ id: 'test-provider' }],
targets: [{ id: 'test-provider' }],
};
vi.mocked(getConfigFromCloud).mockResolvedValue(mockConfig);
const configUUID = '12345678-1234-1234-1234-123456789012';
const targetUUID = '87654321-4321-4321-4321-210987654321';
const customDescription = 'Short Flag Description';
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync([
'node',
'test',
'-c',
configUUID,
'-t',
targetUUID,
'-d',
customDescription,
]);
// Verify that the description was overridden using short flag
expect(mockConfig.description).toBe(customDescription);
});
});
describe('--tag flag for run-specific eval tags', () => {
it('should document the repeatable --tag option in help text', () => {
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
const helpText = runCommand!.helpInformation();
expect(helpText).toContain('--tag <key=value>');
expect(helpText).toContain('Set an eval tag in key=value format.');
});
it('should pass repeated tags to the evaluation stage with Commander normalization', async () => {
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync([
'node',
'test',
'--tag',
'build=first',
'--tag',
'build=second',
'--tag',
'token=a=b',
'--tag',
'empty=',
]);
const options = vi.mocked(doRedteamRun).mock.calls[0][0];
expect(options).toMatchObject({
tags: {
build: 'second',
token: 'a=b',
empty: '',
},
eventSource: 'cli',
});
expect(options).not.toHaveProperty('tag');
});
it('should not add a tags property when no --tag is provided', async () => {
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync(['node', 'test']);
const options = vi.mocked(doRedteamRun).mock.calls[0][0];
// Avoid clobbering config-level tags with an undefined/empty runtime value.
expect(options).not.toHaveProperty('tags');
expect(options).not.toHaveProperty('tag');
});
it.each(['invalid', '=value'])('should reject malformed --tag value %s', (tagValue) => {
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
runCommand!.exitOverride();
expect(() => runCommand!.parseOptions(['--tag', tagValue])).toThrow(
'--tag must be specified in key=value format.',
);
});
});
describe('error handling', () => {
it('should suppress stack trace when doRedteamRun throws ProbeLimitExceededError', async () => {
vi.mocked(doRedteamRun).mockRejectedValueOnce(new ProbeLimitExceededError(100, 100));
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync(['node', 'test']);
expect(logger.error).not.toHaveBeenCalledWith(
expect.stringContaining('An unexpected error occurred during red team run'),
);
expect(process.exitCode).toBe(1);
});
it('should still log unexpected errors with stack', async () => {
const boom = new Error('boom');
vi.mocked(doRedteamRun).mockRejectedValueOnce(boom);
const runCommand = program.commands.find((cmd) => cmd.name() === 'run');
expect(runCommand).toBeDefined();
await runCommand!.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('An unexpected error occurred during red team run: boom'),
);
expect(process.exitCode).toBe(1);
});
});
});