0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
977 lines
31 KiB
TypeScript
977 lines
31 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { z } from 'zod';
|
|
import {
|
|
ALIASED_PLUGIN_MAPPINGS,
|
|
ALIASED_PLUGINS,
|
|
COLLECTIONS,
|
|
DEFAULT_NUM_TESTS_PER_PLUGIN,
|
|
DEFAULT_PLUGINS,
|
|
FOUNDATION_PLUGINS,
|
|
HARM_PLUGINS,
|
|
PII_PLUGINS,
|
|
ALL_PLUGINS as REDTEAM_ALL_PLUGINS,
|
|
Severity,
|
|
TEEN_SAFETY_PLUGINS,
|
|
} from '../../src/redteam/constants';
|
|
import {
|
|
pluginOptions,
|
|
RedteamConfigSchema,
|
|
RedteamStrategySchema,
|
|
strategyIdSchema,
|
|
} from '../../src/validators/redteam';
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('RedteamConfigSchema', () => {
|
|
it('should validate basic config', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: ['default'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins).toBeDefined();
|
|
expect(result.strategies).toBeDefined();
|
|
});
|
|
|
|
it('should expand default plugins', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.length).toBeGreaterThan(0);
|
|
expect(result.plugins?.every((p: any) => Array.from(DEFAULT_PLUGINS).includes(p.id))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('should expand foundation plugins', () => {
|
|
const config = {
|
|
plugins: ['foundation'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.length).toBeGreaterThan(0);
|
|
expect(result.plugins?.every((p: any) => Array.from(FOUNDATION_PLUGINS).includes(p.id))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('should expand harmful plugins', () => {
|
|
const config = {
|
|
plugins: ['harmful'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.length).toBeGreaterThan(0);
|
|
expect(result.plugins?.every((p: any) => Object.keys(HARM_PLUGINS).includes(p.id))).toBe(true);
|
|
});
|
|
|
|
it('should expand PII plugins', () => {
|
|
const config = {
|
|
plugins: ['pii'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.length).toBeGreaterThan(0);
|
|
expect(result.plugins?.every((p: any) => Array.from(PII_PLUGINS).includes(p.id))).toBe(true);
|
|
});
|
|
|
|
it('should expand teen safety plugins', () => {
|
|
const config = {
|
|
plugins: ['teen-safety'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.map((plugin) => plugin.id).sort()).toEqual(
|
|
[...TEEN_SAFETY_PLUGINS].sort(),
|
|
);
|
|
});
|
|
|
|
it('should handle plugin with config and severity', () => {
|
|
const config = {
|
|
plugins: [
|
|
{
|
|
id: 'file://test-plugin.js',
|
|
config: { key: 'value' },
|
|
severity: Severity.High,
|
|
numTests: DEFAULT_NUM_TESTS_PER_PLUGIN,
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.[0]).toEqual({
|
|
id: 'file://test-plugin.js',
|
|
config: { key: 'value' },
|
|
severity: Severity.High,
|
|
numTests: DEFAULT_NUM_TESTS_PER_PLUGIN,
|
|
});
|
|
});
|
|
|
|
it('should handle aliased plugins', () => {
|
|
const config = {
|
|
plugins: [Object.keys(ALIASED_PLUGIN_MAPPINGS)[0]],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should validate custom file:// plugins', () => {
|
|
const config = {
|
|
plugins: ['file:///path/to/plugin.js'],
|
|
};
|
|
|
|
expect(() => RedteamConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should reject invalid plugin names', () => {
|
|
const config = {
|
|
plugins: ['invalid-plugin-name'],
|
|
};
|
|
|
|
expect(() => RedteamConfigSchema.parse(config)).toThrow(/Invalid plugin id/);
|
|
});
|
|
|
|
it('should handle numTests override', () => {
|
|
const config = {
|
|
numTests: 5,
|
|
plugins: ['file://test-plugin.js'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.[0].numTests).toBe(5);
|
|
});
|
|
|
|
it('should inherit top-level numTests for object plugins that omit numTests', () => {
|
|
const config = {
|
|
numTests: 17,
|
|
plugins: [{ id: 'file://test-plugin.js' }],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.[0].numTests).toBe(17);
|
|
});
|
|
|
|
it('should use default numTests when not specified', () => {
|
|
const config = {
|
|
plugins: [
|
|
{
|
|
id: 'file://test-plugin.js',
|
|
numTests: DEFAULT_NUM_TESTS_PER_PLUGIN,
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.[0].numTests).toBe(DEFAULT_NUM_TESTS_PER_PLUGIN);
|
|
});
|
|
|
|
it('should deduplicate plugins', () => {
|
|
const config = {
|
|
plugins: ['file://test-plugin.js', 'file://test-plugin.js'],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins).toHaveLength(1);
|
|
});
|
|
|
|
it('should handle collection plugins', () => {
|
|
const config = {
|
|
plugins: COLLECTIONS,
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should validate RedteamPluginObjectSchema', () => {
|
|
const validPlugin = {
|
|
id: 'file://test-plugin.js',
|
|
numTests: 5,
|
|
config: { key: 'value' },
|
|
severity: Severity.High,
|
|
};
|
|
|
|
const config = {
|
|
plugins: [validPlugin],
|
|
};
|
|
|
|
expect(() => RedteamConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should handle plugin with severity but no config', () => {
|
|
const config = {
|
|
plugins: [
|
|
{
|
|
id: 'file://test-plugin.js',
|
|
severity: Severity.Low,
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.[0].severity).toBe(Severity.Low);
|
|
expect(result.plugins?.[0].config).toBeUndefined();
|
|
});
|
|
|
|
it('should preserve severity when expanding collections', () => {
|
|
const config = {
|
|
plugins: [
|
|
{
|
|
id: 'foundation',
|
|
severity: Severity.Critical,
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins?.every((p) => p.severity === Severity.Critical)).toBe(true);
|
|
});
|
|
|
|
it('should handle strategy with config', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [{ id: 'default', config: { key: 'value' } }],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.strategies?.[0]).toEqual(expect.objectContaining({ config: { key: 'value' } }));
|
|
});
|
|
|
|
it('should handle custom file:// strategies', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: ['file:///path/to/strategy.js'],
|
|
};
|
|
|
|
expect(() => RedteamConfigSchema.parse(config)).not.toThrow();
|
|
});
|
|
|
|
it('should handle empty plugins array', () => {
|
|
const config = {
|
|
plugins: [],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.plugins).toEqual([]);
|
|
});
|
|
|
|
it('should handle config with all optional fields', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: ['basic'],
|
|
numTests: 10,
|
|
language: 'en',
|
|
injectVar: 'prompt',
|
|
purpose: 'Testing LLM robustness',
|
|
entities: ['Company A', 'Product B'],
|
|
maxConcurrency: 5,
|
|
delay: 1000,
|
|
provider: 'openai:gpt-4',
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
expect(result.numTests).toBe(10);
|
|
expect(result.language).toBe('en');
|
|
expect(result.injectVar).toBe('prompt');
|
|
expect(result.purpose).toBe('Testing LLM robustness');
|
|
expect(result.entities).toEqual(['Company A', 'Product B']);
|
|
expect(result.delay).toBe(1000);
|
|
expect(result.provider).toBe('openai:gpt-4');
|
|
});
|
|
|
|
it('should reject invalid numTests values', () => {
|
|
const invalidConfigs = [
|
|
{ plugins: ['default'], numTests: 0 }, // Zero
|
|
{ plugins: ['default'], numTests: -5 }, // Negative
|
|
{ plugins: ['default'], numTests: 1.5 }, // Decimal
|
|
{ plugins: ['default'], numTests: 'five' as any }, // String
|
|
];
|
|
|
|
invalidConfigs.forEach((config) => {
|
|
expect(() => RedteamConfigSchema.parse(config)).toThrow(z.ZodError);
|
|
});
|
|
});
|
|
|
|
it('should reject invalid delay values', () => {
|
|
const invalidConfigs = [
|
|
{ plugins: ['default'], delay: -100 }, // Negative delay
|
|
{ plugins: ['default'], delay: 'slow' as any }, // String
|
|
];
|
|
|
|
invalidConfigs.forEach((config) => {
|
|
expect(() => RedteamConfigSchema.parse(config)).toThrow(z.ZodError);
|
|
});
|
|
});
|
|
|
|
it('should properly transform and deduplicate complex plugin configurations', () => {
|
|
const config = {
|
|
plugins: [
|
|
'default',
|
|
'default', // Duplicate
|
|
{ id: 'contracts', numTests: 10 },
|
|
{ id: 'contracts', numTests: 10 }, // Exact duplicate
|
|
{ id: 'contracts', numTests: 5 }, // Different numTests - this one will be kept
|
|
'file://custom1.js',
|
|
'file://custom1.js', // Duplicate file
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// The transform function deduplicates based on key: `${id}:${JSON.stringify(config)}:${severity || ''}`
|
|
// So contracts with different numTests are different plugins
|
|
const contractsPlugins = result.plugins?.filter((p) => p.id === 'contracts') || [];
|
|
expect(contractsPlugins.length).toBeGreaterThan(0);
|
|
|
|
// File plugins are deduplicated
|
|
const customPlugins = result.plugins?.filter((p) => p.id === 'file://custom1.js') || [];
|
|
expect(customPlugins).toHaveLength(1); // Deduplicated
|
|
|
|
// Default plugins should be expanded
|
|
const hasDefaultPlugins = result.plugins?.some((p) => DEFAULT_PLUGINS.has(p.id as any));
|
|
expect(hasDefaultPlugins).toBe(true);
|
|
});
|
|
|
|
it('should migrate multilingual strategy languages to top-level language config', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [{ id: 'multilingual', config: { languages: ['es', 'fr'] } }],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Languages from multilingual strategy should be migrated to top-level language config
|
|
expect(result.language).toEqual(['en', 'es', 'fr']);
|
|
|
|
// Multilingual strategy should be removed from strategies
|
|
const hasMultilingual = result.strategies?.some(
|
|
(s) => (typeof s === 'string' ? s : s.id) === 'multilingual',
|
|
);
|
|
expect(hasMultilingual).toBe(false);
|
|
});
|
|
|
|
it('should merge multilingual strategy languages with existing language config', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [{ id: 'multilingual', config: { languages: ['de', 'it'] } }],
|
|
language: 'ja',
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Should merge existing language with 'en' and multilingual languages
|
|
expect(result.language).toEqual(['ja', 'en', 'de', 'it']);
|
|
});
|
|
});
|
|
|
|
describe('pluginOptions', () => {
|
|
it('should not contain duplicate entries', () => {
|
|
const duplicates = pluginOptions.filter((item, index) => pluginOptions.indexOf(item) !== index);
|
|
expect(duplicates).toEqual([]);
|
|
});
|
|
|
|
it('should contain unique values from all source arrays', () => {
|
|
const manualSet = new Set([...COLLECTIONS, ...REDTEAM_ALL_PLUGINS, ...ALIASED_PLUGINS]);
|
|
|
|
expect(pluginOptions).toHaveLength(manualSet.size);
|
|
expect(new Set(pluginOptions).size).toBe(pluginOptions.length);
|
|
});
|
|
|
|
it('should be sorted alphabetically', () => {
|
|
const sortedCopy = [...pluginOptions].sort();
|
|
expect(pluginOptions).toEqual(sortedCopy);
|
|
});
|
|
|
|
it('should contain all expected plugin types', () => {
|
|
const hasCollectionItem = COLLECTIONS.some((item) => pluginOptions.includes(item));
|
|
const hasRedteamPlugin = REDTEAM_ALL_PLUGINS.some((item) => pluginOptions.includes(item));
|
|
const hasAliasedPlugin = ALIASED_PLUGINS.some((item) => pluginOptions.includes(item));
|
|
|
|
expect(hasCollectionItem).toBe(true);
|
|
expect(hasRedteamPlugin).toBe(true);
|
|
expect(hasAliasedPlugin).toBe(true);
|
|
});
|
|
|
|
it('should handle the specific bias duplicate case', () => {
|
|
const biasCount = pluginOptions.filter((option) => option === 'bias').length;
|
|
expect(biasCount).toBe(1);
|
|
});
|
|
|
|
it('should deduplicate when source arrays have overlapping values', () => {
|
|
// This test is specifically for the bias duplicate case
|
|
const biasInCollections = COLLECTIONS.includes('bias' as any);
|
|
const biasInAliased = ALIASED_PLUGINS.includes('bias' as any);
|
|
|
|
expect(biasInCollections).toBe(true);
|
|
expect(biasInAliased).toBe(true);
|
|
|
|
const biasOccurrences = pluginOptions.filter((p) => p === 'bias');
|
|
expect(biasOccurrences).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('redteam validators', () => {
|
|
describe('strategyIdSchema', () => {
|
|
describe('valid built-in strategies', () => {
|
|
it('should accept standard built-in strategies', () => {
|
|
const validStrategies = [
|
|
'basic',
|
|
'jailbreak',
|
|
'crescendo',
|
|
'goat',
|
|
'multilingual',
|
|
'base64',
|
|
'hex',
|
|
'rot13',
|
|
];
|
|
|
|
validStrategies.forEach((strategy) => {
|
|
expect(() => strategyIdSchema.parse(strategy)).not.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('custom strategy validation', () => {
|
|
it('should accept simple custom strategy', () => {
|
|
expect(() => strategyIdSchema.parse('custom')).not.toThrow();
|
|
});
|
|
|
|
it('should accept custom strategy variants with compound IDs', () => {
|
|
const customVariants = [
|
|
'custom:aggressive',
|
|
'custom:greeting-strategy',
|
|
'custom:multi-word-variant',
|
|
'custom:snake_case_variant',
|
|
'custom:variant123',
|
|
'custom:CamelCaseVariant',
|
|
'custom:variant.with.dots',
|
|
'custom:very-long-complex-variant-name-with-many-hyphens',
|
|
];
|
|
|
|
customVariants.forEach((variant) => {
|
|
expect(() => strategyIdSchema.parse(variant)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
it('should accept edge case custom strategy formats', () => {
|
|
// These are actually valid custom strategies according to isCustomStrategy
|
|
const validCustomStrategies = [
|
|
'custom:', // Valid - empty variant
|
|
'custom::', // Valid - double colon variant
|
|
'custom:variant:', // Valid - trailing colon variant
|
|
];
|
|
|
|
validCustomStrategies.forEach((strategy) => {
|
|
expect(() => strategyIdSchema.parse(strategy)).not.toThrow();
|
|
});
|
|
|
|
// Test invalid strategies that don't match any pattern
|
|
const invalidStrategies = [
|
|
':custom', // Invalid - doesn't start with 'custom'
|
|
'invalid-strategy-123', // Invalid - not a built-in strategy
|
|
];
|
|
|
|
invalidStrategies.forEach((strategy) => {
|
|
expect(() => strategyIdSchema.parse(strategy)).toThrow(z.ZodError);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('file-based strategy validation', () => {
|
|
it('should accept valid file:// strategy paths', () => {
|
|
const validFilePaths = [
|
|
'file://strategy.js',
|
|
'file://strategy.ts',
|
|
'file://./strategy.js',
|
|
'file:///absolute/path/strategy.ts',
|
|
'file://relative/path/strategy.js',
|
|
];
|
|
|
|
validFilePaths.forEach((path) => {
|
|
expect(() => strategyIdSchema.parse(path)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
it('should reject invalid file:// strategy paths', () => {
|
|
const invalidFilePaths = [
|
|
'file://strategy.txt',
|
|
'file://strategy.py',
|
|
'file://strategy',
|
|
'strategy.js',
|
|
'file:/strategy.js',
|
|
];
|
|
|
|
invalidFilePaths.forEach((path) => {
|
|
let error: z.ZodError | undefined;
|
|
|
|
expect(() => {
|
|
try {
|
|
strategyIdSchema.parse(path);
|
|
} catch (e) {
|
|
if (e instanceof z.ZodError) {
|
|
error = e;
|
|
}
|
|
throw e;
|
|
}
|
|
}).toThrow(z.ZodError);
|
|
|
|
expect(error).toBeDefined();
|
|
const message = error?.issues[0].message || '';
|
|
// Zod v4 may return "Invalid input" for union failures
|
|
const hasValidMessage =
|
|
message.includes('Custom strategies must start with file:// and end with .js or .ts') ||
|
|
message.includes('Invalid input');
|
|
expect(hasValidMessage).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('invalid strategy names', () => {
|
|
it('should reject unknown strategy names', () => {
|
|
// Test string strategies
|
|
const invalidStringStrategies = [
|
|
'unknown-strategy',
|
|
'invalid_strategy',
|
|
'not-a-strategy',
|
|
'fakeStrategy',
|
|
];
|
|
|
|
invalidStringStrategies.forEach((strategy) => {
|
|
let error: z.ZodError | undefined;
|
|
|
|
expect(() => {
|
|
try {
|
|
strategyIdSchema.parse(strategy);
|
|
} catch (e) {
|
|
if (e instanceof z.ZodError) {
|
|
error = e;
|
|
}
|
|
throw e;
|
|
}
|
|
}).toThrow(z.ZodError);
|
|
|
|
expect(error).toBeDefined();
|
|
const message = error?.issues[0].message || '';
|
|
const validMessages = [
|
|
'Custom strategies must start with file:// and end with .js or .ts',
|
|
'Strategy must be one of the built-in strategies:',
|
|
'Invalid enum value',
|
|
'Invalid input', // Zod v4 generic union error
|
|
];
|
|
const hasValidMessage = validMessages.some((msg) => message.includes(msg));
|
|
expect(hasValidMessage).toBe(true);
|
|
});
|
|
|
|
// Test null/undefined/empty separately
|
|
expect(() => strategyIdSchema.parse(null)).toThrow(z.ZodError);
|
|
expect(() => strategyIdSchema.parse(undefined)).toThrow(z.ZodError);
|
|
expect(() => strategyIdSchema.parse('')).toThrow(z.ZodError);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('RedteamStrategySchema', () => {
|
|
describe('string format strategies', () => {
|
|
it('should accept valid strategy strings', () => {
|
|
const validStrategies = [
|
|
'basic',
|
|
'custom',
|
|
'custom:variant',
|
|
'jailbreak',
|
|
'crescendo',
|
|
'file://strategy.js',
|
|
];
|
|
|
|
validStrategies.forEach((strategy) => {
|
|
expect(() => RedteamStrategySchema.parse(strategy)).not.toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('object format strategies', () => {
|
|
it('should accept strategy objects with valid IDs', () => {
|
|
const validStrategyObjects = [
|
|
{ id: 'basic' },
|
|
{ id: 'custom' },
|
|
{ id: 'custom:aggressive' },
|
|
{ id: 'jailbreak', config: { enabled: true } },
|
|
{ id: 'custom:greeting', config: { strategyText: 'Be polite', stateful: true } },
|
|
{ id: 'multilingual', config: { languages: ['es', 'fr'] } },
|
|
{ id: 'file://custom.js', config: { param: 'value' } },
|
|
];
|
|
|
|
validStrategyObjects.forEach((strategy) => {
|
|
expect(() => RedteamStrategySchema.parse(strategy)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
it('should accept custom strategy objects with complex configurations', () => {
|
|
const customStrategyWithConfig = {
|
|
id: 'custom:complex-variant',
|
|
config: {
|
|
strategyText:
|
|
'If current round is 0, generatedQuestion should be just "hi" by itself...',
|
|
stateful: true,
|
|
temperature: 0.8,
|
|
maxTokens: 100,
|
|
customParam: true,
|
|
nestedConfig: {
|
|
subParam: 'value',
|
|
subArray: [1, 2, 3],
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(() => RedteamStrategySchema.parse(customStrategyWithConfig)).not.toThrow();
|
|
});
|
|
|
|
it('should reject strategy objects with invalid IDs', () => {
|
|
// Test objects with invalid IDs
|
|
const invalidIdObjects = [
|
|
{ id: 'custom-invalid' },
|
|
{ id: 'notcustom:variant' },
|
|
{ id: 'file://invalid.txt' },
|
|
{ id: '' },
|
|
];
|
|
|
|
invalidIdObjects.forEach((strategy) => {
|
|
let error: z.ZodError | undefined;
|
|
|
|
expect(() => {
|
|
try {
|
|
RedteamStrategySchema.parse(strategy);
|
|
} catch (e) {
|
|
if (e instanceof z.ZodError) {
|
|
error = e;
|
|
}
|
|
throw e;
|
|
}
|
|
}).toThrow(z.ZodError);
|
|
|
|
expect(error).toBeDefined();
|
|
const message = error?.issues[0].message || '';
|
|
const validMessages = [
|
|
'Custom strategies must start with file:// and end with .js or .ts',
|
|
'Strategy must be one of the built-in strategies:',
|
|
'Invalid enum value',
|
|
'Invalid input', // Zod v4 generic union error
|
|
];
|
|
const hasValidMessage = validMessages.some((msg) => message.includes(msg));
|
|
expect(hasValidMessage).toBe(true);
|
|
});
|
|
|
|
// Test object without id separately
|
|
expect(() => RedteamStrategySchema.parse({})).toThrow(z.ZodError);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle strategy objects with empty config', () => {
|
|
const strategyWithEmptyConfig = { id: 'custom:variant', config: {} };
|
|
expect(() => RedteamStrategySchema.parse(strategyWithEmptyConfig)).not.toThrow();
|
|
});
|
|
|
|
it('should handle strategy objects with undefined config', () => {
|
|
const strategyWithUndefinedConfig = { id: 'custom:variant', config: undefined };
|
|
expect(() => RedteamStrategySchema.parse(strategyWithUndefinedConfig)).not.toThrow();
|
|
});
|
|
|
|
it('should handle strategy objects without config property', () => {
|
|
const strategyWithoutConfig = { id: 'custom:variant' };
|
|
expect(() => RedteamStrategySchema.parse(strategyWithoutConfig)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('error messages', () => {
|
|
it('should provide helpful error message for invalid file strategy', () => {
|
|
let error: z.ZodError | undefined;
|
|
|
|
expect(() => {
|
|
try {
|
|
strategyIdSchema.parse('file://strategy.txt');
|
|
} catch (e) {
|
|
if (e instanceof z.ZodError) {
|
|
error = e;
|
|
}
|
|
throw e;
|
|
}
|
|
}).toThrow(z.ZodError);
|
|
|
|
expect(error).toBeDefined();
|
|
const message = error?.issues[0].message || '';
|
|
// Zod v4 may return "Invalid input" for union failures or the specific message
|
|
const hasValidMessage =
|
|
message.includes('Custom strategies must start with file:// and end with .js or .ts') ||
|
|
message.includes('Invalid input');
|
|
expect(hasValidMessage).toBe(true);
|
|
});
|
|
|
|
it('should provide helpful error message for completely invalid strategy', () => {
|
|
let error: z.ZodError | undefined;
|
|
|
|
expect(() => {
|
|
try {
|
|
strategyIdSchema.parse('totally-invalid-strategy');
|
|
} catch (e) {
|
|
if (e instanceof z.ZodError) {
|
|
error = e;
|
|
}
|
|
throw e;
|
|
}
|
|
}).toThrow(z.ZodError);
|
|
|
|
expect(error).toBeDefined();
|
|
const message = error?.issues[0].message || '';
|
|
const validMessages = [
|
|
'Custom strategies must start with file:// and end with .js or .ts',
|
|
'Strategy must be one of the built-in strategies:',
|
|
'Invalid enum value',
|
|
'Invalid input', // Zod v4 generic union error
|
|
];
|
|
const hasValidMessage = validMessages.some((msg) => message.includes(msg));
|
|
expect(hasValidMessage).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('integration with actual use cases', () => {
|
|
it('should validate realistic custom strategy configurations', () => {
|
|
const realisticConfigurations = [
|
|
'custom:greeting-strategy',
|
|
'custom:default-strategy',
|
|
{
|
|
id: 'custom:greeting-strategy',
|
|
config: {
|
|
stateful: true,
|
|
strategyText:
|
|
"If current round is 0, generatedQuestion should be just 'Hello can you help me?' by itself...",
|
|
},
|
|
},
|
|
{
|
|
id: 'custom:default-strategy',
|
|
config: {
|
|
stateful: true,
|
|
strategyText:
|
|
"If current round is 0, generatedQuestion should be just 'hi' by itself...",
|
|
},
|
|
},
|
|
];
|
|
|
|
realisticConfigurations.forEach((config) => {
|
|
expect(() => RedteamStrategySchema.parse(config)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
it('should validate mixed strategy arrays like those used in real configurations', () => {
|
|
const mixedStrategies = [
|
|
'basic',
|
|
{ id: 'jailbreak', config: { enabled: true } },
|
|
'custom:aggressive-variant',
|
|
{
|
|
id: 'custom:polite-variant',
|
|
config: {
|
|
strategyText: 'Be very polite and formal',
|
|
stateful: false,
|
|
},
|
|
},
|
|
'crescendo',
|
|
'file://./my-custom-strategy.js',
|
|
];
|
|
|
|
mixedStrategies.forEach((strategy) => {
|
|
expect(() => RedteamStrategySchema.parse(strategy)).not.toThrow();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('layer strategy deduplication', () => {
|
|
it('should keep multiple layer strategies with different labels', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [
|
|
{ id: 'layer', config: { label: 'hydra-audio', steps: ['jailbreak:hydra', 'audio'] } },
|
|
{ id: 'layer', config: { label: 'crescendo-audio', steps: ['crescendo', 'audio'] } },
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Both layer strategies should be kept (different labels)
|
|
const layerStrategies = result.strategies?.filter(
|
|
(s) => typeof s !== 'string' && s.id === 'layer',
|
|
);
|
|
expect(layerStrategies).toHaveLength(2);
|
|
});
|
|
|
|
it('should keep multiple layer strategies with different steps', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [
|
|
{ id: 'layer', config: { steps: ['jailbreak:hydra', 'audio'] } },
|
|
{ id: 'layer', config: { steps: ['crescendo', 'image'] } },
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Both layer strategies should be kept (different steps)
|
|
const layerStrategies = result.strategies?.filter(
|
|
(s) => typeof s !== 'string' && s.id === 'layer',
|
|
);
|
|
expect(layerStrategies).toHaveLength(2);
|
|
});
|
|
|
|
it('should deduplicate identical layer strategies', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [
|
|
{ id: 'layer', config: { label: 'same-label', steps: ['jailbreak:hydra', 'audio'] } },
|
|
{ id: 'layer', config: { label: 'same-label', steps: ['jailbreak:hydra', 'audio'] } },
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Duplicate layer strategies should be deduplicated
|
|
const layerStrategies = result.strategies?.filter(
|
|
(s) => typeof s !== 'string' && s.id === 'layer',
|
|
);
|
|
expect(layerStrategies).toHaveLength(1);
|
|
});
|
|
|
|
it('should use label for deduplication key when provided', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [
|
|
{ id: 'layer', config: { label: 'unique-label', steps: ['jailbreak:hydra', 'audio'] } },
|
|
{ id: 'layer', config: { label: 'unique-label', steps: ['crescendo', 'image'] } }, // Same label, different steps
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Should only keep one (label is the key, so second overwrites first)
|
|
const layerStrategies = result.strategies?.filter(
|
|
(s) => typeof s !== 'string' && s.id === 'layer',
|
|
);
|
|
expect(layerStrategies).toHaveLength(1);
|
|
// The last one wins
|
|
expect(layerStrategies?.[0]).toEqual(
|
|
expect.objectContaining({
|
|
config: expect.objectContaining({ steps: ['crescendo', 'image'] }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle layer strategies without label or steps', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [{ id: 'layer', config: {} }],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
const layerStrategies = result.strategies?.filter(
|
|
(s) => typeof s !== 'string' && s.id === 'layer',
|
|
);
|
|
expect(layerStrategies).toHaveLength(1);
|
|
});
|
|
|
|
it('should handle mixed layer and non-layer strategies', () => {
|
|
const config = {
|
|
plugins: ['default'],
|
|
strategies: [
|
|
'basic',
|
|
{ id: 'layer', config: { label: 'audio-attack', steps: ['jailbreak:hydra', 'audio'] } },
|
|
'jailbreak',
|
|
{ id: 'layer', config: { label: 'image-attack', steps: ['crescendo', 'image'] } },
|
|
],
|
|
};
|
|
|
|
const result = RedteamConfigSchema.parse(config);
|
|
|
|
// Should have all strategies
|
|
expect(result.strategies?.length).toBeGreaterThanOrEqual(3);
|
|
|
|
// Layer strategies should both be present
|
|
const layerStrategies = result.strategies?.filter(
|
|
(s) => typeof s !== 'string' && s.id === 'layer',
|
|
);
|
|
expect(layerStrategies).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('Error message quality', () => {
|
|
it('should provide clear error messages for common mistakes', () => {
|
|
// Test various common mistakes and ensure error messages are helpful
|
|
// Note: Zod v4 may return "Invalid input" for union failures
|
|
const testCases = [
|
|
{
|
|
config: { plugins: ['non-existent-plugin'] },
|
|
expectedError: /Custom plugins must start with file:\/\/|Invalid input/,
|
|
description: 'non-existent plugin name',
|
|
},
|
|
{
|
|
config: { strategies: ['file://strategy.json'] },
|
|
expectedError:
|
|
/Custom strategies must start with file:\/\/ and end with .js or .ts|Invalid input/,
|
|
description: 'wrong file extension for strategy',
|
|
},
|
|
{
|
|
config: { strategies: ['invalid-strategy-name'] },
|
|
expectedError:
|
|
/Custom strategies must start with file:\/\/ and end with .js or .ts|Invalid input/,
|
|
description: 'invalid strategy name',
|
|
},
|
|
{
|
|
config: { plugins: [{ id: 'default' }], numTests: 'many' as any },
|
|
expectedError: /Expected number|Invalid type|Invalid input|expected number/i,
|
|
description: 'string instead of number for numTests',
|
|
},
|
|
{
|
|
config: { plugins: ['default'], delay: 'slow' as any },
|
|
expectedError: /Expected number|Invalid type|Invalid input|expected number/i,
|
|
description: 'string instead of number for delay',
|
|
},
|
|
];
|
|
|
|
testCases.forEach(({ config, expectedError }) => {
|
|
let errorCaught = false;
|
|
let zodError: z.ZodError | null = null;
|
|
|
|
try {
|
|
RedteamConfigSchema.parse(config);
|
|
} catch (error) {
|
|
errorCaught = true;
|
|
if (error instanceof z.ZodError) {
|
|
zodError = error;
|
|
}
|
|
}
|
|
|
|
expect(errorCaught).toBe(true);
|
|
expect(zodError).not.toBeNull();
|
|
|
|
const message = zodError!.issues[0]?.message || '';
|
|
|
|
// Always perform the assertion, the expected pattern handles both string and regex
|
|
const matches =
|
|
typeof expectedError === 'string'
|
|
? message.includes(expectedError)
|
|
: expectedError.test(message);
|
|
|
|
expect(matches).toBe(true);
|
|
});
|
|
});
|
|
});
|