Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/e2e/numeric-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const SCENARIO: RegisteredScenario = {
{ name: 'iterations', type_name: 'int', required: false, default: '2', choices: null, is_list: false },
{ name: 'temperature', type_name: 'float', required: false, default: '0.5', choices: null, is_list: false },
],
supported_parameter_names: [
'objective_target', 'scenario_techniques', 'dataset_config', 'memory_labels',
'max_concurrency', 'max_retries', 'include_baseline', 'iterations', 'temperature',
],
default_run_size: {
dataset_size: { kind: 'indeterminate', reason: 'configuration_unavailable', detail: 'Population configuration is not available.' },
dataset_limit: { state: 'scenario_default' },
Expand Down
4 changes: 4 additions & 0 deletions frontend/e2e/onboarding-tour.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const TOUR_SCENARIO: RegisteredScenario = {
baseline_policy: "disabled",
include_baseline_by_default: false,
supported_parameters: [],
supported_parameter_names: [
"objective_target", "scenario_techniques", "dataset_config", "memory_labels",
"max_concurrency", "max_retries", "include_baseline",
],
default_run_size: {
status: "unavailable",
dataset_size: { kind: "indeterminate", detail: "Mock estimate only." },
Expand Down
5 changes: 5 additions & 0 deletions frontend/e2e/scenario-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ const catalogScenario = {
baseline_policy: "enabled",
include_baseline_by_default: false,
uses_default_adversarial_target: false,
supported_parameter_names: [
"objective_target", "scenario_techniques", "dataset_config", "memory_labels",
"max_concurrency", "max_retries", "include_baseline", "num_jailbreaks",
"num_jailbreak_attempts", "jailbreak_names",
],
supported_parameters: [
{
name: "num_jailbreaks",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.labels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ const SCENARIO: RegisteredScenario = {
baseline_policy: 'forbidden',
include_baseline_by_default: false,
supported_parameters: [],
supported_parameter_names: [
'objective_target', 'scenario_techniques', 'dataset_config', 'memory_labels',
'max_concurrency', 'max_retries', 'include_baseline',
],
uses_default_adversarial_target: false,
default_run_size: {
dataset_size: { kind: 'bounded', value: 1 },
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Scenarios/ScenarioCatalog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function makeScenario(overrides: Partial<RegisteredScenario> & { scenario_name:
include_baseline_by_default: true,
uses_default_adversarial_target: true,
supported_parameters: [],
supported_parameter_names: [],
default_run_size: {
dataset_size: { kind: 'indeterminate', detail: 'Population configuration is not available.' },
dataset_limit: { state: 'scenario_default' },
Expand Down
141 changes: 141 additions & 0 deletions frontend/src/components/Scenarios/ScenarioDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { MemoryRouter, Route, Routes } from 'react-router'

import { scenariosApi, targetsApi } from '@/services/api'
import { useRuntime } from '@/hooks/useRuntime'
import type {
RegisteredScenario,
ScenarioRunSizeEstimateResponse,
Expand All @@ -12,6 +13,8 @@ import type {

import ScenarioDetail from './ScenarioDetail'

jest.mock('@/hooks/useRuntime', () => ({ useRuntime: jest.fn() }))

jest.mock('@/services/api', () => ({
scenariosApi: {
estimateRun: jest.fn(),
Expand All @@ -27,6 +30,7 @@ const mockGetScenario = scenariosApi.getScenario as jest.Mock
const mockEstimateRun = scenariosApi.estimateRun as jest.Mock
const mockStartRun = scenariosApi.startRun as jest.Mock
const mockListTargets = targetsApi.listTargets as jest.Mock
const mockUseRuntime = jest.mocked(useRuntime)

const mockNavigate = jest.fn()
const RAW_IMAGE_HTML = ['<', 'img src=x onerror="alert(1)">'].join('')
Expand Down Expand Up @@ -63,6 +67,11 @@ function makeScenario(overrides: Partial<RegisteredScenario> = {}): RegisteredSc
include_baseline_by_default: true,
uses_default_adversarial_target: true,
supported_parameters: [],
supported_parameter_names: overrides.supported_parameter_names ?? [
'objective_target', 'scenario_techniques', 'technique_converters', 'dataset_config',
'memory_labels', 'max_concurrency', 'max_retries', 'include_baseline',
...(overrides.supported_parameters ?? []).map((parameter) => parameter.name),
],
default_run_size: {
dataset_size: { kind: 'indeterminate', detail: 'Population configuration is not available.' },
dataset_limit: { state: 'scenario_default' },
Expand Down Expand Up @@ -190,6 +199,7 @@ function renderDetail(
describe('ScenarioDetail', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'first' })
mockGetScenario.mockReset()
mockEstimateRun.mockReset()
mockListTargets.mockReset()
Expand All @@ -203,6 +213,137 @@ describe('ScenarioDetail', () => {
jest.useRealTimers()
})

it('blocks a legacy catalog response without support names instead of guessing common support', async () => {
const legacyScenario = makeScenario()
Reflect.deleteProperty(legacyScenario, 'supported_parameter_names')
mockGetScenario.mockResolvedValue(legacyScenario)

renderDetail('/scanner/foundry.red_team_agent')

expect(await screen.findByText(/catalog has no input support metadata/i)).toBeInTheDocument()
expect(screen.queryByRole('form')).not.toBeInTheDocument()
expect(mockEstimateRun).not.toHaveBeenCalled()
expect(mockStartRun).not.toHaveBeenCalled()
})

it.each([
['dataset_config', 'Dataset override', ['dataset_names', 'max_dataset_size', 'dataset_filters']],
['scenario_techniques', 'default_technique', ['techniques']],
['include_baseline', 'baseline', ['include_baseline']],
['max_concurrency', 'Max concurrency', ['max_concurrency']],
['max_retries', 'Max retries', ['max_retries']],
['memory_labels', null, ['labels']],
['objective_target', 'Objective Target', ['target_name']],
])('omits controls and request aliases for undeclared %s', async (
name: string, label: string | null, requestFields: string[],
) => {
jest.useFakeTimers()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
const scenario = makeScenario()
scenario.supported_parameter_names = scenario.supported_parameter_names.filter(
(parameterName) => parameterName !== name,
)
// Defaults and estimate data do not establish support for an input.
mockGetScenario.mockResolvedValue(scenario)
mockEstimateRun.mockResolvedValue(makeEstimate(8))
renderDetail('/scanner/foundry.red_team_agent')
await flushRenderedPromises()
await advanceTimers(300)

if (label) {
expect(screen.queryByLabelText(label)).not.toBeInTheDocument()
}
if (name === 'dataset_config') {
expect(screen.queryByLabelText('Max dataset size')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Harm categories')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Data types')).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: 'No total dataset limit' })).not.toBeInTheDocument()
}
expect(mockEstimateRun).toHaveBeenCalledTimes(1)
const preview = await openRunPreview(user)
if (name === 'dataset_config') {
expect(within(preview).queryByText('Datasets')).not.toBeInTheDocument()
expect(within(preview).queryByText('Dataset filters')).not.toBeInTheDocument()
}
await user.click(screen.getByTestId('confirm-launch-scenario-btn'))
expect(mockStartRun).toHaveBeenCalledTimes(1)
for (const field of requestFields) {
expect(mockEstimateRun.mock.calls[0][1]).not.toHaveProperty(field)
expect(mockStartRun.mock.calls[0][0]).not.toHaveProperty(field)
}
})

it('keeps baseline selection independent when attack technique selection is unsupported', async () => {
const user = userEvent.setup()
mockGetScenario.mockResolvedValue(makeScenario({
supported_parameter_names: ['objective_target', 'include_baseline'],
all_techniques: [],
default_techniques: [],
}))
renderDetail('/scanner/foundry.red_team_agent')
const baseline = await screen.findByRole('checkbox', { name: 'baseline' })
await user.click(baseline)
await confirmRunPreview(user)

expect(mockStartRun).toHaveBeenCalledWith({
scenario_name: 'foundry.red_team_agent',
target_name: 'target-a',
include_baseline: false,
})
expect(screen.queryByText('Select at least one attack technique.')).not.toBeInTheDocument()
})

it('drops unsupported invalid drafts after catalog refresh and preserves supported values', async () => {
jest.useFakeTimers()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
const parameters = [
{ name: 'iterations', type_name: 'int', required: true, default: null, choices: null, is_list: false },
{ name: 'note', type_name: 'str', required: true, default: null, choices: null, is_list: false },
]
mockGetScenario.mockResolvedValue(makeScenario({ supported_parameters: parameters }))
mockEstimateRun.mockResolvedValue(makeEstimate(8))
const detail = renderDetail('/scanner/foundry.red_team_agent')
await flushRenderedPromises()
await user.type(screen.getByLabelText(/^iterations/), '1.5')
await user.type(screen.getByLabelText(/^note/), 'Keep this value')
await user.type(screen.getByLabelText('Dataset override'), 'stale-dataset')
await user.type(screen.getByLabelText('Max dataset size'), '0')
await user.type(screen.getByLabelText('Harm categories'), 'stale-harm')
await user.type(screen.getByLabelText('Data types'), 'stale-type')
for (const label of ['Max concurrency', 'Max retries']) {
const input = screen.getByRole('spinbutton', { name: label })
await user.clear(input)
await user.type(input, '500')
await user.tab()
}
await user.click(screen.getByRole('button', { name: 'Launch scan' }))
expect(screen.getByRole('alert')).toHaveTextContent('iterations must be an integer.')

mockGetScenario.mockResolvedValue(makeScenario({
supported_parameter_names: ['note'],
// A removed external descriptor must not validate or submit a stale value.
supported_parameters: parameters,
uses_default_adversarial_target: false,
}))
mockUseRuntime.mockReturnValue({ ready: true, state: 'ready', generation: 'second' })
detail.updateDefaults({ targets: [] })
await flushRenderedPromises()
await user.click(screen.getByRole('button', { name: 'I have reviewed the refreshed selections' }))
await advanceTimers(300)

expect(screen.getByLabelText(/^note/)).toHaveValue('Keep this value')
expect(screen.queryByLabelText(/^iterations/)).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(mockEstimateRun.mock.calls.at(-1)?.[1]).toEqual({
scenario_params: { note: 'Keep this value' },
})
await confirmRunPreview(user)
expect(mockStartRun).toHaveBeenCalledWith({
scenario_name: 'foundry.red_team_agent',
scenario_params: { note: 'Keep this value' },
})
})

it('shows a loading state while fetching', () => {
mockGetScenario.mockReturnValue(new Promise(() => {}))
renderDetail('/scanner/foundry.red_team_agent')
Expand Down
Loading
Loading