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