import { describe, expect, it, vi } from 'vitest'; // Mock logger for tests that need it vi.mock('../../src/logger', () => ({ default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); import { type BasePlugin, COLLECTIONS, FOUNDATION_PLUGINS, HARM_PLUGINS, PII_PLUGINS, type PIIPlugin, ALL_PLUGINS as REDTEAM_ALL_PLUGINS, ALL_STRATEGIES as REDTEAM_ALL_STRATEGIES, DEFAULT_PLUGINS as REDTEAM_DEFAULT_PLUGINS, } from '../../src/redteam/constants'; import { CODING_AGENT_CORE_PLUGINS, CODING_AGENT_PLUGINS, } from '../../src/redteam/constants/codingAgents'; import { InputsSchema } from '../../src/redteam/types'; import { RedteamConfigSchema, RedteamContextSchema, RedteamGenerateOptionsSchema, RedteamPluginObjectSchema, RedteamPluginSchema, RedteamStrategySchema, } from '../../src/validators/redteam'; import type { RedteamPluginObject, RedteamStrategy } from '../../src/redteam/types'; describe('RedteamPluginObjectSchema', () => { it('should validate valid plugin object', () => { const validPlugin = { id: 'pii:direct', numTests: 5, config: { key: 'value' }, }; const result = RedteamPluginObjectSchema.safeParse(validPlugin); expect(result.success).toBe(true); }); it('should reject invalid plugin id', () => { const invalidPlugin = { id: 'invalid-plugin', numTests: 5, }; const result = RedteamPluginObjectSchema.safeParse(invalidPlugin); expect(result.success).toBe(false); }); it('should allow custom plugin paths starting with file://', () => { const customPlugin = { id: 'file:///path/to/plugin.js', numTests: 5, }; const result = RedteamPluginObjectSchema.safeParse(customPlugin); expect(result.success).toBe(true); }); }); describe('redteamGenerateOptionsSchema', () => { it('should accept valid options for a redteam test', () => { const input = { cache: true, config: 'promptfooconfig.yaml', defaultConfig: { temperature: 0.7 }, injectVar: 'query', numTests: 50, output: 'sample-results.json', plugins: [{ id: 'harmful:hate' }], provider: 'openai:gpt-4', purpose: 'You are an expert content moderator', write: true, }; expect(RedteamGenerateOptionsSchema.safeParse(input)).toEqual({ success: true, data: { cache: true, config: 'promptfooconfig.yaml', defaultConfig: { temperature: 0.7 }, force: false, injectVar: 'query', numTests: 50, output: 'sample-results.json', plugins: [{ id: 'harmful:hate', numTests: 5 }], provider: 'openai:gpt-4', purpose: 'You are an expert content moderator', strict: false, write: true, }, }); }); it('should validate basic generate options', () => { const options = { cache: true, defaultConfig: {}, force: false, write: true, burpEscapeJson: true, progressBar: true, }; const result = RedteamGenerateOptionsSchema.safeParse(options); expect(result.success).toBe(true); }); it('should validate optional fields', () => { const options = { cache: true, defaultConfig: {}, write: true, plugins: [{ id: 'pii:direct', numTests: 5 }], strategies: ['basic'], maxConcurrency: 5, maxCharsPerMessage: 125, delay: 1000, filterProviders: 'openai', filterTargets: 'target-team-b', }; const result = RedteamGenerateOptionsSchema.safeParse(options); expect(result.success).toBe(true); if (result.success) { expect(result.data.filterProviders).toBe('openai'); expect(result.data.filterTargets).toBe('target-team-b'); } }); it('should reject invalid plugin names', () => { const input = { plugins: ['harmful:medical'], numTests: 10, }; expect(RedteamGenerateOptionsSchema.safeParse(input).success).toBe(false); }); it('should require numTests to be a positive integer', () => { const input = { numTests: -5, plugins: ['harmful:hate'], }; expect(RedteamGenerateOptionsSchema.safeParse(input).success).toBe(false); }); it('should require maxCharsPerMessage to be a positive integer', () => { const input = { cache: true, defaultConfig: {}, write: true, maxCharsPerMessage: 0, }; expect(RedteamGenerateOptionsSchema.safeParse(input).success).toBe(false); }); }); describe('redteamPluginSchema', () => { it('should accept a valid plugin name as a string', () => { expect(RedteamPluginSchema.safeParse('hijacking').success).toBe(true); }); it('should accept a valid plugin object', () => { const input = { id: 'harmful:hate', numTests: 30, }; expect(RedteamPluginSchema.safeParse(input).success).toBe(true); }); it('should reject an invalid plugin name', () => { expect(RedteamPluginSchema.safeParse('invalid-plugin-name').success).toBe(false); }); it('should reject a plugin object with negative numTests', () => { const input = { id: 'jailbreak', numTests: -10, }; expect(RedteamPluginSchema.safeParse(input).success).toBe(false); }); it('should allow omitting numTests in a plugin object', () => { const input = { id: 'hijacking', }; expect(RedteamPluginSchema.safeParse(input).success).toBe(true); }); it('should provide helpful error message for invalid plugin names', () => { const result = RedteamPluginSchema.safeParse('hate'); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; expect(errorMessage).toContain('Invalid plugin id'); expect(errorMessage).toContain('built-in plugin'); expect(errorMessage).toContain('https://www.promptfoo.dev/docs/red-team/plugins'); }); it('should provide helpful error message for invalid file:// paths', () => { const result = RedteamPluginSchema.safeParse('path/to/plugin.js'); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; expect(errorMessage).toContain('Invalid plugin id'); expect(errorMessage).toContain('built-in plugin'); expect(errorMessage).toContain('https://www.promptfoo.dev/docs/red-team/plugins'); }); it('should provide helpful error message for invalid plugin object', () => { const result = RedteamPluginSchema.safeParse({ id: 'invalid-plugin', numTests: 5, }); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; expect(errorMessage).toContain('Invalid plugin id'); expect(errorMessage).toContain('built-in plugin'); expect(errorMessage).toContain('https://www.promptfoo.dev/docs/red-team/plugins'); }); }); describe('redteamConfigSchema', () => { it.each([ { id: 'missing-purpose' }, { id: 'empty-purpose', purpose: '' }, { id: 'blank-purpose', purpose: ' ' }, ])('should accept contexts with optional or blank purposes: $id', (context) => { const result = RedteamContextSchema.safeParse(context); expect(result.success).toBe(true); if (!result.success) { return; } if (context.purpose === undefined) { expect(result.data.purpose).toBeUndefined(); } else { expect(result.data.purpose).toBeTypeOf('string'); expect(result.data.purpose?.trim()).toBe(''); } }); it('should accept a valid configuration with all fields', () => { const input = { purpose: 'You are a travel agent', numTests: 3, maxCharsPerMessage: 125, plugins: [ { id: 'harmful:non-violent-crime', numTests: 5 }, { id: 'hijacking', numTests: 3 }, ], strategies: ['prompt-injection'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { purpose: 'You are a travel agent', numTests: 3, maxCharsPerMessage: 125, plugins: [ { id: 'harmful:non-violent-crime', numTests: 5 }, { id: 'hijacking', numTests: 3 }, ], strategies: [{ id: 'prompt-injection' }], }, }); }); it('should use default values when fields are omitted', () => { const input = {}; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { plugins: expect.arrayContaining( Array(REDTEAM_DEFAULT_PLUGINS.size).fill(expect.any(Object)), ), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should allow omitting the purpose field', () => { const input = { numTests: 10 }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 10, plugins: expect.arrayContaining( Array(REDTEAM_DEFAULT_PLUGINS.size).fill(expect.any(Object)), ), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should transform string plugins to objects', () => { const input = { plugins: ['hijacking', 'overreliance'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: undefined, plugins: [{ id: 'hijacking' }, { id: 'overreliance' }], strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should use global numTests for plugins without specified numTests', () => { const input = { numTests: 7, plugins: [ { id: 'harmful:non-violent-crime', numTests: 7 }, { id: 'hijacking', numTests: 3 }, { id: 'overreliance', numTests: 7 }, ], strategies: ['jailbreak'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 7, plugins: [ { id: 'harmful:non-violent-crime', numTests: 7 }, { id: 'hijacking', numTests: 3 }, { id: 'overreliance', numTests: 7 }, ], strategies: [{ id: 'jailbreak' }], }, }); }); it('should reject invalid plugin names', () => { const input = { plugins: ['invalid-plugin-name'], }; expect(RedteamConfigSchema.safeParse(input).success).toBe(false); }); it('should reject negative numTests', () => { const input = { numTests: -1, }; expect(RedteamConfigSchema.safeParse(input).success).toBe(false); }); it('should reject invalid maxCharsPerMessage values', () => { const input = { maxCharsPerMessage: 0, }; expect(RedteamConfigSchema.safeParse(input).success).toBe(false); }); it('should reject non-integer numTests', () => { const input = { numTests: 3.5, }; expect(RedteamConfigSchema.safeParse(input).success).toBe(false); }); it('should allow all valid plugin and strategy names', () => { // Test with a smaller subset to prevent memory issues on macOS Node 24 const samplePlugins = REDTEAM_ALL_PLUGINS.slice(0, 20); const sampleStrategies = REDTEAM_ALL_STRATEGIES.filter( (id) => id !== 'default' && id !== 'basic', ).slice(0, 10); const input = { plugins: samplePlugins, strategies: sampleStrategies, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data?.plugins).toBeDefined(); expect(result.data?.strategies).toBeDefined(); // Verify the structure is correct const collectionIds = new Set(COLLECTIONS); const filteredPlugins = samplePlugins.filter((id) => !collectionIds.has(id)); expect(result.data?.plugins).toHaveLength(filteredPlugins.length); expect(result.data?.strategies?.length).toBeGreaterThan(0); }); it('should validate every defined plugin id individually', () => { for (const pluginId of REDTEAM_ALL_PLUGINS) { expect(RedteamPluginSchema.safeParse(pluginId).success, pluginId).toBe(true); } }); it('should validate every defined strategy id individually', () => { for (const strategyId of REDTEAM_ALL_STRATEGIES) { expect(RedteamStrategySchema.safeParse(strategyId).success, strategyId).toBe(true); } }); it('should expand harmful plugin to all harm categories', () => { const input = { plugins: ['harmful'], numTests: 3, }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 3, plugins: Object.keys(HARM_PLUGINS) .map((category) => ({ id: category, numTests: 3, })) .sort((a, b) => a.id.localeCompare(b.id)), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should allow overriding specific harm categories', () => { const input = { plugins: [ { id: 'harmful:hate', numTests: 10 }, 'harmful', { id: 'harmful:violent-crime', numTests: 5 }, ], numTests: 3, }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 3, plugins: [ { id: 'harmful:hate', numTests: 10 }, { id: 'harmful:violent-crime', numTests: 5 }, ...Object.keys(HARM_PLUGINS) .filter((category) => !['harmful:hate', 'harmful:violent-crime'].includes(category)) .map((category) => ({ id: category, numTests: 3, })), ].sort((a, b) => a.id.localeCompare(b.id)), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); expect(RedteamConfigSchema.safeParse(input)?.data?.plugins).toHaveLength( Object.keys(HARM_PLUGINS).length, ); }); it('should not duplicate harm categories when specified individually', () => { const input = { plugins: ['harmful', 'harmful:hate', { id: 'harmful:violent-crime', numTests: 5 }], numTests: 3, }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 3, plugins: expect.arrayContaining([ { id: 'harmful:hate', numTests: 3 }, { id: 'harmful:violent-crime', numTests: 5 }, ...Object.keys(HARM_PLUGINS) .filter((category) => !['harmful:hate', 'harmful:violent-crime'].includes(category)) .map((category) => ({ id: category, numTests: 3 })), ]), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should handle harmful categories without specifying harmful plugin', () => { const input = { plugins: [{ id: 'harmful:hate', numTests: 10 }, 'harmful:violent-crime'], numTests: 3, }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 3, plugins: expect.arrayContaining([ { id: 'harmful:hate', numTests: 10 }, { id: 'harmful:violent-crime', numTests: 3 }, ]), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should reject invalid harm categories', () => { const input = { plugins: ['harmful:invalid-category'], }; expect(RedteamConfigSchema.safeParse(input).success).toBe(false); }); it('should accept an array of injectVar strings', () => { const input = { injectVar: 'system', plugins: ['harmful:insults'], strategies: ['jailbreak'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { injectVar: 'system', plugins: [{ id: 'harmful:insults', numTests: undefined }], strategies: [{ id: 'jailbreak' }], }, }); }); it('should accept a provider string', () => { const input = { provider: 'openai:gpt-4o-mini', plugins: ['overreliance'], strategies: ['jailbreak'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { provider: 'openai:gpt-4o-mini', plugins: [{ id: 'overreliance', numTests: undefined }], strategies: [{ id: 'jailbreak' }], }, }); }); it('should accept a language string', () => { const input = { language: 'German', plugins: ['overreliance'], strategies: ['jailbreak'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { language: 'German', plugins: [{ id: 'overreliance', config: undefined, numTests: undefined }], strategies: [{ id: 'jailbreak' }], }, }); }); it('should include injectVar, provider, and purpose when all are provided', () => { const input = { injectVar: 'system', provider: 'openai:gpt-4', purpose: 'Test adversarial inputs', plugins: ['overreliance', 'politics'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { injectVar: 'system', provider: 'openai:gpt-4', purpose: 'Test adversarial inputs', plugins: [ { id: 'overreliance', numTests: undefined }, { id: 'politics', numTests: undefined }, ], strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); }); it('should accept a provider object with id and config', () => { const input = { provider: { id: 'openai:gpt-4', config: { temperature: 0.7, max_tokens: 100, }, }, plugins: ['overreliance'], strategies: ['jailbreak'], }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { provider: { id: 'openai:gpt-4', config: { temperature: 0.7, max_tokens: 100, }, }, plugins: [{ id: 'overreliance', numTests: undefined }], strategies: [{ id: 'jailbreak' }], }, }); }); it('should accept a provider object with callApi function', () => { const mockCallApi = vi.fn(); const input = { provider: { id: () => 'custom-provider', callApi: mockCallApi, label: 'Custom Provider', }, plugins: ['overreliance'], strategies: ['jailbreak'], }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data?.provider).toHaveProperty('id'); expect(result.data?.provider).toHaveProperty('callApi'); expect(result.data?.provider).toHaveProperty('label', 'Custom Provider'); expect(result.data?.plugins).toEqual([{ id: 'overreliance', numTests: undefined }]); expect(result.data?.strategies).toEqual([{ id: 'jailbreak' }]); }); it('should reject an invalid provider', () => { const input = { provider: 123, // Invalid provider plugins: ['overreliance'], strategies: ['jailbreak'], }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(false); }); it('should handle plugins with config attributes', () => { const input = { plugins: [ { id: 'policy', config: { policy: 'Policy 1' }, numTests: 5 }, { id: 'policy', config: { policy: 'Policy 2' }, numTests: 3 }, { id: 'harmful:hate', numTests: 10 }, 'harmful', ], numTests: 2, }; expect(RedteamConfigSchema.safeParse(input)).toEqual({ success: true, data: { numTests: 2, plugins: [ { id: 'harmful:hate', numTests: 10 }, { id: 'policy', config: { policy: 'Policy 1' }, numTests: 5 }, { id: 'policy', config: { policy: 'Policy 2' }, numTests: 3 }, ...Object.keys(HARM_PLUGINS) .filter((category) => category !== 'harmful:hate') .map((category) => ({ id: category, numTests: 2 })), ].sort((a, b) => a.id.localeCompare(b.id)), strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], }, }); expect(RedteamConfigSchema.safeParse(input)?.data?.plugins).toHaveLength( Object.keys(HARM_PLUGINS).length + 2, // +2 for the two policy plugins ); }); it('should handle PII plugins with default numTests', () => { const input = { plugins: [ { id: 'pii', numTests: 7 }, { id: 'pii:session', numTests: 3 }, ], numTests: 5, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data).toBeDefined(); const piiPlugins = result.data?.plugins?.filter((p) => p.id.startsWith('pii:')); expect(piiPlugins).toEqual( expect.arrayContaining([ { id: 'pii:session', numTests: 3 }, ...PII_PLUGINS.filter((id) => id !== 'pii:session').map((id) => ({ id, numTests: 7 })), ]), ); expect(piiPlugins).toHaveLength(PII_PLUGINS.length); }); it('should sort plugins with different configurations correctly', () => { const input = { plugins: [ { id: 'policy', config: { policy: 'Policy B' }, numTests: 3 }, { id: 'policy', config: { policy: 'Policy A' }, numTests: 5 }, { id: 'hijacking', numTests: 2 }, ], }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data?.plugins).toEqual([ { id: 'hijacking', numTests: 2 }, { id: 'policy', config: { policy: 'Policy A' }, numTests: 5 }, { id: 'policy', config: { policy: 'Policy B' }, numTests: 3 }, ]); }); describe('aliases', () => { it('should expand high-level aliased plugin names', () => { const input = { plugins: ['owasp:llm'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); const expectedPlugins = [ 'harmful:violent-crime', 'harmful:non-violent-crime', 'harmful:sex-crime', 'harmful:child-exploitation', 'harmful:indiscriminate-weapons', 'harmful:hate', 'harmful:self-harm', 'harmful:sexual-content', 'harmful:cybercrime', 'harmful:chemical-biological-weapons', 'harmful:illegal-drugs', 'harmful:copyright-violations', 'harmful:harassment-bullying', 'harmful:illegal-activities', 'harmful:graphic-content', 'harmful:unsafe-practices', 'harmful:radicalization', 'harmful:profanity', 'harmful:insults', 'harmful:privacy', 'harmful:intellectual-property', 'harmful:misinformation-disinformation', 'harmful:specialized-advice', 'pii:api-db', 'pii:direct', 'pii:session', 'pii:social', 'overreliance', 'hallucination', ]; expect(result.data?.plugins).toEqual( expect.arrayContaining( expectedPlugins.map((id) => expect.objectContaining({ id, numTests: 3 })), ), ); }); it('should expand granular aliased plugin names', () => { const input = { plugins: ['owasp:llm:01'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); const expectedPlugins = [ 'harmful:violent-crime', 'harmful:non-violent-crime', 'harmful:sex-crime', 'harmful:child-exploitation', 'harmful:indiscriminate-weapons', 'harmful:hate', 'harmful:self-harm', 'harmful:sexual-content', 'harmful:cybercrime', 'harmful:chemical-biological-weapons', 'harmful:illegal-drugs', 'harmful:copyright-violations', 'harmful:harassment-bullying', 'harmful:illegal-activities', 'harmful:graphic-content', 'harmful:unsafe-practices', 'harmful:radicalization', 'harmful:profanity', 'harmful:insults', 'harmful:privacy', 'harmful:intellectual-property', 'harmful:misinformation-disinformation', 'harmful:specialized-advice', ]; expect(result.data?.plugins).toEqual( expect.arrayContaining( expectedPlugins.map((id) => expect.objectContaining({ id, numTests: 3 })), ), ); }); it('should expand collections within aliased plugin names', () => { const input = { plugins: ['nist:ai:measure:2.1'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); const expectedPlugins = [ 'harmful:privacy', 'pii:api-db', 'pii:direct', 'pii:session', 'pii:social', ]; expect(result.data?.plugins).toEqual( expect.arrayContaining( expectedPlugins.map((id) => expect.objectContaining({ id, numTests: 3 })), ), ); }); it('should not duplicate plugins when using multiple aliased names', () => { const input = { plugins: ['owasp:llm:01', 'owasp:llm:02', 'owasp:llm:04'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); const actualPlugins = result.data?.plugins || []; // Test key plugins exist to avoid memory issues with full list const keyPluginsToCheck = [ 'harmful:violent-crime', 'harmful:hate', 'pii:direct', 'ascii-smuggling', 'bias:age', ]; for (const pluginId of keyPluginsToCheck) { const found = actualPlugins.find((p) => p.id === pluginId && p.numTests === 3); expect(found).toBeDefined(); } // Verify we have a reasonable number of plugins (should be 40+) expect(actualPlugins.length).toBeGreaterThan(35); // Verify no duplicates const pluginIds = actualPlugins.map((p) => p.id); const uniqueIds = new Set(pluginIds); expect(pluginIds.length).toBe(uniqueIds.size); }); it('should expand strategies for "owasp:llm" alias', () => { const input = { plugins: ['owasp:llm'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data?.strategies).toEqual( expect.arrayContaining([ { id: 'basic' }, { id: 'jailbreak' }, { id: 'jailbreak:composite' }, { id: 'jailbreak-templates' }, ]), ); // Ensure no duplicates const strategies = result.data!.strategies!; const strategyIds = new Set(strategies.map((s) => (typeof s === 'string' ? s : s.id))); expect(strategies).toHaveLength(strategyIds.size); }); it('should expand strategies for "owasp:llm:01" alias', () => { const input = { plugins: ['owasp:llm:01'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data?.strategies).toEqual( expect.arrayContaining([ { id: 'basic' }, { id: 'jailbreak' }, { id: 'jailbreak:composite' }, { id: 'jailbreak-templates' }, ]), ); // Ensure no duplicates const strategies = result.data!.strategies!; const strategyIds = new Set(strategies.map((s) => (typeof s === 'string' ? s : s.id))); expect(strategies).toHaveLength(strategyIds.size); }); it('should expand current and legacy MITRE ATLAS aliases', () => { const currentAlias = RedteamConfigSchema.safeParse({ plugins: ['mitre:atlas:persistence'], numTests: 3, }); expect(currentAlias.success).toBe(true); expect(currentAlias.data?.plugins).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'agentic:memory-poisoning', numTests: 3 }), expect.objectContaining({ id: 'rag-poisoning', numTests: 3 }), ]), ); const legacyAlias = RedteamConfigSchema.safeParse({ plugins: ['mitre:atlas:ml-attack-staging'], numTests: 3, }); expect(legacyAlias.success).toBe(true); expect(legacyAlias.data?.plugins).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'ascii-smuggling', numTests: 3 }), expect.objectContaining({ id: 'rag-poisoning', numTests: 3 }), ]), ); }); it('should not duplicate strategies when using multiple aliased names', () => { const input = { plugins: ['owasp:llm', 'owasp:llm:01', 'owasp:llm:02'], numTests: 3, }; const result = RedteamConfigSchema.safeParse(input); expect(result.success).toBe(true); expect(result.data?.strategies).toEqual( expect.arrayContaining([ { id: 'basic' }, { id: 'jailbreak' }, { id: 'jailbreak:composite' }, { id: 'jailbreak-templates' }, ]), ); // Ensure no duplicates const strategies = result.data!.strategies!; const strategyIds = new Set(strategies.map((s) => (typeof s === 'string' ? s : s.id))); expect(strategies).toHaveLength(strategyIds.size); }); }); describe('severity handling', () => { it('should preserve severity field in plugin objects', () => { const config = { plugins: [ { id: 'harmful:hate', numTests: 1, severity: 'low' }, { id: 'harmful:self-harm', numTests: 1, severity: 'low' }, ], }; const result = RedteamConfigSchema.parse(config); expect(result.plugins!).toHaveLength(2); expect(result.plugins![0]).toEqual({ id: 'harmful:hate', numTests: 1, severity: 'low', }); expect(result.plugins![1]).toEqual({ id: 'harmful:self-harm', numTests: 1, severity: 'low', }); }); it('should handle plugins without severity field', () => { const config = { plugins: [ { id: 'harmful:hate', numTests: 1 }, { id: 'harmful:self-harm', numTests: 1, severity: 'high' }, ], }; const result = RedteamConfigSchema.parse(config); expect(result.plugins!).toHaveLength(2); expect(result.plugins![0]).toEqual({ id: 'harmful:hate', numTests: 1, }); expect(result.plugins![1]).toEqual({ id: 'harmful:self-harm', numTests: 1, severity: 'high', }); }); it('should preserve severity when expanding collections', () => { const config = { plugins: [{ id: 'harmful', numTests: 1, severity: 'medium' }], }; const result = RedteamConfigSchema.parse(config); // All expanded harmful plugins should have the severity const harmfulPlugins = result.plugins!.filter((p) => p.id.startsWith('harmful:')); expect(harmfulPlugins.length).toBeGreaterThan(0); harmfulPlugins.forEach((plugin) => { expect(plugin.severity).toBe('medium'); }); }); }); }); describe('RedteamConfigSchema transform', () => { it('should deduplicate plugins with same id and config', () => { const result = RedteamConfigSchema.parse({ plugins: [ { id: 'contracts', config: { key: 'value' } }, { id: 'contracts', config: { key: 'value' } }, ], }); expect(result.plugins).toHaveLength(1); expect(result.plugins?.[0]).toEqual({ id: 'contracts', config: { key: 'value' }, numTests: 5, }); }); it('should keep plugins with same id but different configs', () => { const result = RedteamConfigSchema.parse({ plugins: [ { id: 'contracts', config: { key: 'value1' } }, { id: 'contracts', config: { key: 'value2' }, numTests: 2 }, ], }); expect(result.plugins).toHaveLength(2); expect(result.plugins).toEqual([ { id: 'contracts', config: { key: 'value1' }, numTests: 5 }, { id: 'contracts', config: { key: 'value2' }, numTests: 2 }, ]); }); it('should sort plugins by id and then by config', () => { const result = RedteamConfigSchema.parse({ plugins: [ { id: 'contracts', config: { key: 'b' } }, { id: 'harmful:hate' }, { id: 'contracts', config: { key: 'a' } }, ], }); expect( result.plugins?.map((p: RedteamPluginObject) => ({ id: p.id, config: p.config })), ).toEqual([ { id: 'contracts', config: { key: 'a' } }, { id: 'contracts', config: { key: 'b' } }, { id: 'harmful:hate' }, ]); }); it('should filter out collection plugins from final output', () => { const result = RedteamConfigSchema.parse({ plugins: ['harmful', 'pii', 'default'], }); // Verify no collection names appear in final plugin list expect( result.plugins?.some((p: RedteamPluginObject) => ['harmful', 'pii', 'default'].includes(p.id), ), ).toBe(false); }); it('should expand collection plugins correctly', () => { const result = RedteamConfigSchema.parse({ numTests: 5, plugins: ['harmful'], }); // Should expand 'harmful' into individual harm categories // Note: bias plugins are separate from harmful plugins now expect(result.plugins?.every((p: RedteamPluginObject) => p.id.startsWith('harmful:'))).toBe( true, ); expect(result.plugins?.every((p: RedteamPluginObject) => p.numTests === 5)).toBe(true); }); it('should expand coding-agent collections correctly', () => { const coreResult = RedteamConfigSchema.parse({ numTests: 5, plugins: ['coding-agent:core'], }); expect(coreResult.plugins?.map((plugin) => plugin.id)).toEqual( expect.arrayContaining([...CODING_AGENT_CORE_PLUGINS]), ); expect(coreResult.plugins).toHaveLength(CODING_AGENT_CORE_PLUGINS.length); expect(coreResult.plugins?.every((plugin) => plugin.numTests === 5)).toBe(true); const allResult = RedteamConfigSchema.parse({ numTests: 10, plugins: ['coding-agent:all'], }); expect(allResult.plugins?.map((plugin) => plugin.id)).toEqual( expect.arrayContaining([...CODING_AGENT_PLUGINS]), ); expect(allResult.plugins).toHaveLength(CODING_AGENT_PLUGINS.length); expect(allResult.plugins?.every((plugin) => plugin.numTests === 10)).toBe(true); }); it('should handle plugin aliases and their associated strategies', () => { const result = RedteamConfigSchema.parse({ plugins: ['harmful:hate'], }); // Check if associated strategies were added expect( result.strategies?.some((s: RedteamStrategy) => { if (typeof s === 'string' || !s) { throw new Error('Strategy should be an object'); } return s.id === 'jailbreak:meta'; }), ).toBe(true); expect( result.strategies?.some((s: RedteamStrategy) => { if (typeof s === 'string' || !s) { throw new Error('Strategy should be an object'); } return s.id === 'jailbreak:composite'; }), ).toBe(true); }); it('should preserve numTests hierarchy (plugin > global)', () => { const result = RedteamConfigSchema.parse({ numTests: 5, plugins: ['contracts', { id: 'harmful:hate', numTests: 10 }, { id: 'overreliance' }], }); const contractsPlugin = result.plugins?.find((p) => p.id === 'contracts'); const hatePlugin = result.plugins?.find((p) => p.id === 'harmful:hate'); const overreliancePlugin = result.plugins?.find((p) => p.id === 'overreliance'); expect(contractsPlugin?.numTests).toBe(5); expect(hatePlugin?.numTests).toBe(10); expect(overreliancePlugin?.numTests).toBe(5); }); it('should handle file:// paths in transform', () => { const result = RedteamConfigSchema.parse({ plugins: ['file://path/to/plugin.js'], strategies: ['file://path/to/strategy.js'], }); expect(result.plugins?.[0]?.id).toBe('file://path/to/plugin.js'); const firstStrategy = result.strategies?.[0]; if (typeof firstStrategy === 'string' || !firstStrategy) { throw new Error('First strategy should be an object'); } expect(firstStrategy.id).toBe('file://path/to/strategy.js'); }); describe('file:// plugins', () => { it('should accept file:// plugins in string format', () => { const result = RedteamConfigSchema.parse({ plugins: ['file://path/to/plugin.js'], }); expect(result.plugins?.[0]).toEqual({ id: 'file://path/to/plugin.js', }); }); it('should accept file:// plugins in object format', () => { const result = RedteamConfigSchema.parse({ plugins: [ { id: 'file://path/to/plugin.js', numTests: 10, config: { custom: 'value' }, }, ], }); expect(result.plugins?.[0]).toEqual({ id: 'file://path/to/plugin.js', numTests: 10, config: { custom: 'value' }, }); }); it('should handle mix of file:// and built-in plugins', () => { const result = RedteamConfigSchema.parse({ plugins: [ 'file://path/to/plugin.js', 'harmful:hate', { id: 'file://another/plugin.js', numTests: 3 }, ], }); expect(result.plugins).toEqual([ { id: 'file://another/plugin.js', numTests: 3 }, { id: 'file://path/to/plugin.js' }, { id: 'harmful:hate' }, ]); }); it('should reject non-file:// custom paths', () => { expect(() => RedteamConfigSchema.parse({ plugins: ['custom/path/without/file/protocol.js'], }), ).toThrow(/Invalid plugin id/); }); }); it('should include all optional fields in transformed output', () => { const input = { plugins: ['harmful:hate'], delay: 1000, entities: ['ACME Corp', 'John Doe'], injectVar: 'system', language: 'en', provider: 'openai:gpt-4', purpose: 'Testing', numTests: 5, }; const result = RedteamConfigSchema.parse(input); expect(result).toEqual({ plugins: [{ id: 'harmful:hate', numTests: 5 }], strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], delay: 1000, entities: ['ACME Corp', 'John Doe'], injectVar: 'system', language: 'en', provider: 'openai:gpt-4', purpose: 'Testing', numTests: 5, }); }); it('should omit undefined optional fields from transformed output', () => { const input = { plugins: ['harmful:hate'], numTests: 5, }; const result = RedteamConfigSchema.parse(input); expect(result).toEqual({ plugins: [{ id: 'harmful:hate', numTests: 5 }], strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], numTests: 5, }); // Verify optional fields are not present expect(result).not.toHaveProperty('delay'); expect(result).not.toHaveProperty('entities'); expect(result).not.toHaveProperty('injectVar'); expect(result).not.toHaveProperty('language'); expect(result).not.toHaveProperty('provider'); expect(result).not.toHaveProperty('purpose'); }); it('should handle entities array in configuration', () => { const input = { plugins: ['harmful:hate'], entities: ['Company X', 'Jane Smith', 'Acme Industries'], numTests: 3, }; const result = RedteamConfigSchema.parse(input); expect(result).toEqual({ plugins: [{ id: 'harmful:hate', numTests: 3 }], strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], entities: ['Company X', 'Jane Smith', 'Acme Industries'], numTests: 3, }); }); it('should handle empty entities array', () => { const input = { plugins: ['harmful:hate'], entities: [], numTests: 3, }; const result = RedteamConfigSchema.parse(input); expect(result).toEqual({ plugins: [{ id: 'harmful:hate', numTests: 3 }], strategies: [{ id: 'basic' }, { id: 'jailbreak:composite' }, { id: 'jailbreak:meta' }], entities: [], numTests: 3, }); }); it('should preserve order of optional fields in transformed output', () => { const input = { plugins: ['harmful:hate'], delay: 1000, entities: ['ACME Corp'], injectVar: 'system', language: 'en', provider: 'openai:gpt-4', purpose: 'Testing', }; const result = RedteamConfigSchema.parse(input); const keys = Object.keys(result); const optionalFields = keys.filter((key) => ['delay', 'entities', 'injectVar', 'language', 'provider', 'purpose'].includes(key), ); // Verify the order matches the spread order in the transform expect(optionalFields).toEqual([ 'delay', 'entities', 'injectVar', 'language', 'provider', 'purpose', ]); }); it('should expand harmful plugin to all harm categories', () => { const result = RedteamConfigSchema.parse({ numTests: 5, plugins: ['harmful'], }); // Should expand 'harmful' into individual harm categories // Note: bias plugins are separate from harmful plugins now expect(result.plugins?.every((p: RedteamPluginObject) => p.id.startsWith('harmful:'))).toBe( true, ); expect(result.plugins?.every((p: RedteamPluginObject) => p.numTests === 5)).toBe(true); }); it('should expand foundation plugin to all foundation plugins', () => { const result = RedteamConfigSchema.parse({ numTests: 5, plugins: ['foundation'], }); expect( result.plugins?.every((p: RedteamPluginObject) => Array.from(FOUNDATION_PLUGINS).includes(p.id as BasePlugin), ), ).toBe(true); expect(result.plugins?.every((p: RedteamPluginObject) => p.numTests === 5)).toBe(true); }); it('should handle multiple collection plugins including foundation', () => { const result = RedteamConfigSchema.parse({ numTests: 3, plugins: ['foundation', 'harmful', 'pii'], }); expect( result.plugins?.some((p: RedteamPluginObject) => Array.from(FOUNDATION_PLUGINS).includes(p.id as BasePlugin), ), ).toBe(true); expect(result.plugins?.some((p: RedteamPluginObject) => p.id.startsWith('harmful:'))).toBe( true, ); expect( result.plugins?.some((p: RedteamPluginObject) => PII_PLUGINS.includes(p.id as PIIPlugin)), ).toBe(true); expect( result.plugins?.every( (p: RedteamPluginObject) => !['foundation', 'harmful', 'pii'].includes(p.id), ), ).toBe(true); }); describe('multilingual strategy lifting', () => { it('should lift multilingual strategy languages to global language config', () => { const input = { plugins: ['overreliance'], strategies: [ { id: 'multilingual', config: { languages: ['hi', 'fr'], }, }, ], }; const result = RedteamConfigSchema.parse(input); // Language should be lifted to global config with 'en' prepended as default expect(result.language).toEqual(['en', 'hi', 'fr']); // Multilingual should be removed from strategies array expect( result.strategies?.some((s) => (typeof s === 'string' ? s : s.id) === 'multilingual'), ).toBe(false); }); it('should merge multilingual languages with existing global language config', () => { const input = { plugins: ['overreliance'], language: 'de', strategies: [ { id: 'multilingual', config: { languages: ['hi', 'fr'], }, }, ], }; const result = RedteamConfigSchema.parse(input); // Should merge and deduplicate with 'en' added expect(result.language).toEqual(['de', 'en', 'hi', 'fr']); // Multilingual should be removed from strategies array expect( result.strategies?.some((s) => (typeof s === 'string' ? s : s.id) === 'multilingual'), ).toBe(false); }); it('should preserve other strategies when removing multilingual', () => { const input = { plugins: ['overreliance'], strategies: [ 'jailbreak', { id: 'multilingual', config: { languages: ['hi'], }, }, 'prompt-injection', ], }; const result = RedteamConfigSchema.parse(input); expect(result.language).toEqual(['en', 'hi']); // Should keep other strategies but remove multilingual const strategyIds = result.strategies?.map((s) => (typeof s === 'string' ? s : s.id)); expect(strategyIds).toContain('jailbreak'); expect(strategyIds).toContain('prompt-injection'); expect(strategyIds).not.toContain('multilingual'); }); it('should handle multilingual as string (not lifting)', () => { const input = { plugins: ['overreliance'], strategies: ['multilingual'], }; const result = RedteamConfigSchema.parse(input); // String form doesn't have config, so no lifting should occur expect(result.language).toBeUndefined(); // Multilingual as string should be kept (no lifting occurred) expect( result.strategies?.some((s) => (typeof s === 'string' ? s : s.id) === 'multilingual'), ).toBe(true); }); it('should not lift if multilingual has no languages', () => { const input = { plugins: ['overreliance'], strategies: [ { id: 'multilingual', config: { languages: [], }, }, ], }; const result = RedteamConfigSchema.parse(input); // No lifting should occur with empty languages expect(result.language).toBeUndefined(); // Multilingual should still be removed? Or kept? Let's keep it for empty config expect( result.strategies?.some((s) => (typeof s === 'string' ? s : s.id) === 'multilingual'), ).toBe(true); }); }); }); describe('RedteamStrategySchema', () => { it('should accept valid built-in strategy names', () => { expect(RedteamStrategySchema.safeParse('jailbreak').success).toBe(true); expect(RedteamStrategySchema.safeParse('prompt-injection').success).toBe(true); }); it('should accept valid file:// paths', () => { expect(RedteamStrategySchema.safeParse('file://path/to/strategy.js').success).toBe(true); expect(RedteamStrategySchema.safeParse('file://path/to/strategy.ts').success).toBe(true); }); it('should provide helpful error message for invalid strategy names', () => { const result = RedteamStrategySchema.safeParse('invalid-strategy'); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; // Zod v4 may return "Invalid input" for union failures const hasValidMessage = (errorMessage.includes('Custom strategies must start with file://') && errorMessage.includes('built-in strategies:')) || errorMessage.includes('Invalid input'); expect(hasValidMessage).toBe(true); }); it('should provide helpful error message for invalid file:// paths', () => { const result = RedteamStrategySchema.safeParse('file://path/to/strategy.invalid'); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; // Zod v4 may return "Invalid input" for union failures const hasValidMessage = (errorMessage.includes('Custom strategies must start with file://') && errorMessage.includes('.js or .ts')) || errorMessage.includes('Invalid input'); expect(hasValidMessage).toBe(true); }); it('should provide helpful error message for non-file:// paths', () => { const result = RedteamStrategySchema.safeParse('path/to/strategy.js'); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; // Zod v4 may return "Invalid input" for union failures const hasValidMessage = (errorMessage.includes('Custom strategies must start with file://') && errorMessage.includes('.js or .ts')) || errorMessage.includes('Invalid input'); expect(hasValidMessage).toBe(true); }); it('should provide helpful error message for invalid strategy object', () => { const result = RedteamStrategySchema.safeParse({ id: 'invalid-strategy', config: {}, }); expect(result.success).toBe(false); if (result.success) { return; } const errorMessage = result.error.issues[0].message; // Zod v4 may return "Invalid input" for union failures const hasValidMessage = (errorMessage.includes('Custom strategies must start with file://') && errorMessage.includes('built-in strategies:')) || errorMessage.includes('Invalid input'); expect(hasValidMessage).toBe(true); }); }); describe('InputsSchema', () => { describe('valid inputs', () => { it('should accept valid variable names with string descriptions', () => { const inputs = { username: 'The user name', message: 'The message content', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); expect(result.data).toEqual(inputs); }); it('should accept variable names starting with underscore', () => { const inputs = { _private: 'A private variable', __dunder: 'A dunder variable', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept variable names with numbers (not at start)', () => { const inputs = { user1: 'First user', message2: 'Second message', item123: 'Item 123', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept single character variable names', () => { const inputs = { a: 'Variable a', _: 'Underscore variable', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept camelCase and snake_case variable names', () => { const inputs = { userName: 'Camel case name', user_name: 'Snake case name', userID: 'User ID', user_id: 'Another user ID', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept empty object', () => { const inputs = {}; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept complex multi-input scenarios', () => { const inputs = { userId: 'The unique identifier for the user', action: 'The action being requested (e.g., read, write, delete)', targetResource: 'The resource path being accessed', context: 'Additional context about the request', sessionId: 'The current session identifier', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); }); describe('invalid variable names', () => { it('should reject variable names starting with a number', () => { const inputs = { '1user': 'Invalid - starts with number', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); if (!result.success) { // Zod v4 uses "Invalid key in record" instead of custom message const message = result.error.issues[0].message; expect(message.includes('valid identifiers') || message.includes('Invalid key')).toBe(true); } }); it('should reject variable names with hyphens', () => { const inputs = { 'user-name': 'Invalid - contains hyphen', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); }); it('should reject variable names with spaces', () => { const inputs = { 'user name': 'Invalid - contains space', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); }); it('should reject variable names with special characters', () => { const invalidNames = ['user@name', 'user.name', 'user!', 'user$var', 'user#id']; for (const name of invalidNames) { const inputs = { [name]: 'description' }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); } }); it('should reject empty string variable names', () => { const inputs = { '': 'Empty key', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); }); }); describe('invalid descriptions', () => { it('should reject empty string descriptions', () => { const inputs = { username: '', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues[0].message).toContain('non-empty'); } }); it('should reject whitespace-only descriptions', () => { const inputs = { username: ' ', }; const result = InputsSchema.safeParse(inputs); // Note: This depends on implementation - if using min(1) with no trim, whitespace may pass // Based on the schema using z.string().min(1), whitespace-only should still pass // Let's verify the actual behavior expect(result.success).toBe(true); }); it('should reject non-string descriptions', () => { const inputs = { username: 123, }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); }); it('should reject null descriptions', () => { const inputs = { username: null, }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); }); it('should reject object descriptions', () => { const inputs = { username: { nested: 'value' }, }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(false); }); }); describe('edge cases', () => { it('should accept descriptions with special characters', () => { const inputs = { username: "The user's name (including special chars: @, #, $)", query: 'SQL query: SELECT * FROM users WHERE id = ?', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept multiline descriptions', () => { const inputs = { username: 'The username\nShould be alphanumeric\nMax 50 characters', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept descriptions with unicode characters', () => { const inputs = { message: 'User message content 你好 مرحبا 🎉', }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); it('should accept very long descriptions', () => { const inputs = { context: 'A'.repeat(1000), }; const result = InputsSchema.safeParse(inputs); expect(result.success).toBe(true); }); }); });