Skip to content
Open
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
32 changes: 20 additions & 12 deletions frontend/e2e/history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,23 +161,29 @@ async function mockHistoryAPIs(
return;
}
const url = new URL(route.request().url());
const attackType = url.searchParams.get("attack_type");
const attackTypeParams = url.searchParams.getAll("attack_types");
const outcome = url.searchParams.get("outcome");
const labelParams = url.searchParams.getAll("label");

let filtered = [...attacks];
if (attackType) {
filtered = filtered.filter((a) => a.attack_type === attackType);
if (attackTypeParams.length > 0) {
filtered = filtered.filter((a) => attackTypeParams.includes(a.attack_type));
}
if (outcome) {
filtered = filtered.filter((a) => a.outcome === outcome);
}
if (labelParams.length > 0) {
// Group repeated label keys into OR-sets; combine across keys with AND.
const grouped = new Map<string, string[]>();
for (const lp of labelParams) {
const [key, val] = lp.split(":");
if (!key) continue;
const bucket = grouped.get(key) ?? [];
bucket.push(val ?? "");
grouped.set(key, bucket);
}
filtered = filtered.filter((a) =>
labelParams.every((lp) => {
const [key, val] = lp.split(":");
return a.labels[key] === val;
}),
Array.from(grouped.entries()).every(([key, vals]) => vals.includes(a.labels[key] ?? "")),
);
}

Expand Down Expand Up @@ -210,10 +216,11 @@ test.describe("Attack History Filters", () => {
await expect(page.getByTestId("attack-row-atk-alice-a")).toBeVisible();
await expect(page.getByTestId("attack-row-atk-bob-b")).toBeVisible();

// Open the attack class dropdown and select SingleTurnAttack
const dropdown = page.getByTestId("attack-class-filter");
// Open the attack type combobox. Multiselect Combobox renders items as
// role="menuitemcheckbox", not role="option".
const dropdown = page.getByTestId("attack-type-filter");
await dropdown.click();
await page.getByRole("option", { name: "SingleTurnAttack" }).click();
await page.getByRole("menuitemcheckbox", { name: "SingleTurnAttack" }).click();

// Only SingleTurnAttack attacks should be visible
await expect(page.getByTestId("attack-row-atk-alice-a")).toBeVisible({ timeout: 5_000 });
Expand Down Expand Up @@ -243,10 +250,11 @@ test.describe("Attack History Filters", () => {
await mockHistoryAPIs(page);
await goToHistory(page);

// Open operator dropdown and select "bob"
// Open the operator combobox. Multiselect Combobox renders items as
// role="menuitemcheckbox", not role="option".
const operatorDropdown = page.getByTestId("operator-filter");
await operatorDropdown.click();
await page.getByRole("option", { name: "bob" }).click();
await page.getByRole("menuitemcheckbox", { name: "bob" }).click();

// Only bob's attacks
await expect(page.getByTestId("attack-row-atk-bob-b")).toBeVisible({ timeout: 5_000 });
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/History/AttackHistory.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ export const useAttackHistoryStyles = makeStyles({
gap: tokens.spacingHorizontalXXS,
flexWrap: 'wrap',
},
matchModeToggle: {
display: 'inline-flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
paddingInline: tokens.spacingHorizontalXS,
},
matchModeLabel: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground3,
userSelect: 'none',
},
matchModeLabelActive: {
color: tokens.colorNeutralForeground1,
fontWeight: tokens.fontWeightSemibold,
},
pagination: {
display: 'flex',
justifyContent: 'center',
Expand Down
145 changes: 135 additions & 10 deletions frontend/src/components/History/AttackHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe('AttackHistory', () => {

expect(screen.getByText('Attack History')).toBeInTheDocument()
expect(screen.getByTestId('refresh-btn')).toBeInTheDocument()
expect(screen.getByTestId('attack-class-filter')).toBeInTheDocument()
expect(screen.getByTestId('attack-type-filter')).toBeInTheDocument()
expect(screen.getByTestId('outcome-filter')).toBeInTheDocument()
expect(screen.getByTestId('converter-filter')).toBeInTheDocument()
expect(screen.getByTestId('operator-filter')).toBeInTheDocument()
Expand Down Expand Up @@ -656,7 +656,7 @@ describe('AttackHistory', () => {
expect(screen.queryByTestId('reset-filters-btn')).not.toBeInTheDocument()
})

it('should call onFiltersChange with attackClass when attack type filter is selected', async () => {
it('should call onFiltersChange with attackTypes when attack type filter is selected', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
Expand Down Expand Up @@ -684,8 +684,8 @@ describe('AttackHistory', () => {
expect(mockedAttacksApi.getAttackOptions).toHaveBeenCalled()
})

// Open the attack class dropdown and select an option
const attackDropdown = screen.getByTestId('attack-class-filter')
// Open the attack type dropdown and select an option
const attackDropdown = screen.getByTestId('attack-type-filter')
fireEvent.click(attackDropdown)

await waitFor(() => {
Expand All @@ -695,7 +695,7 @@ describe('AttackHistory', () => {
fireEvent.click(screen.getByText('CrescendoAttack'))

expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ attackClass: 'CrescendoAttack' })
Comment thread
adrian-gavrila marked this conversation as resolved.
expect.objectContaining({ attackTypes: ['CrescendoAttack'] })
)
})

Expand Down Expand Up @@ -767,7 +767,7 @@ describe('AttackHistory', () => {
fireEvent.click(screen.getByText('Base64Converter'))

expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ converter: 'Base64Converter' })
expect.objectContaining({ converter: ['Base64Converter'] })
)
})

Expand Down Expand Up @@ -808,7 +808,7 @@ describe('AttackHistory', () => {
fireEvent.click(screen.getByText('alice'))

expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ operator: 'alice' })
expect.objectContaining({ operator: ['alice'] })
)
})

Expand Down Expand Up @@ -849,7 +849,7 @@ describe('AttackHistory', () => {
fireEvent.click(screen.getByText('op_alpha'))

expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ operation: 'op_alpha' })
expect.objectContaining({ operation: ['op_alpha'] })
)
})

Expand Down Expand Up @@ -900,9 +900,9 @@ describe('AttackHistory', () => {
const onFiltersChange = jest.fn()
const activeFilters = {
...DEFAULT_HISTORY_FILTERS,
attackClass: 'CrescendoAttack',
attackTypes: ['CrescendoAttack'],
outcome: 'success',
operator: 'alice',
operator: ['alice'],
}

render(
Expand Down Expand Up @@ -961,4 +961,129 @@ describe('AttackHistory', () => {
expect.objectContaining({ otherLabels: expect.any(Array), labelSearchText: '' })
)
})

it('should forward multi-select attackTypes as attack_types array to the API', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })

const filters = {
...DEFAULT_HISTORY_FILTERS,
attackTypes: ['CrescendoAttack', 'RedTeamingAttack'],
}

render(
<TestWrapper>
<AttackHistory {...defaultProps} filters={filters} />
</TestWrapper>
)

await waitFor(() => {
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
})

expect(mockedAttacksApi.listAttacks).toHaveBeenCalledWith(
expect.objectContaining({ attack_types: ['CrescendoAttack', 'RedTeamingAttack'] })
)
})

it('should forward hasConverters=false without converter_types or converter_types_match', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })

const filters = {
...DEFAULT_HISTORY_FILTERS,
hasConverters: false,
converter: [],
}

render(
<TestWrapper>
<AttackHistory {...defaultProps} filters={filters} />
</TestWrapper>
)

await waitFor(() => {
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
})

const callArgs = mockedAttacksApi.listAttacks.mock.calls[0][0]
expect(callArgs).toEqual(expect.objectContaining({ has_converters: false }))
expect(callArgs).not.toHaveProperty('converter_types')
expect(callArgs).not.toHaveProperty('converter_types_match')
})

it('should only send converter_types_match when two or more converters are selected', async () => {
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })

// Case 1: single converter → converter_types_match is NOT sent
const singleFilters = {
...DEFAULT_HISTORY_FILTERS,
converter: ['Base64Converter'],
converterMatchMode: 'all' as const,
}

const { unmount } = render(
<TestWrapper>
<AttackHistory {...defaultProps} filters={singleFilters} />
</TestWrapper>
)

await waitFor(() => {
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
})

const singleCallArgs = mockedAttacksApi.listAttacks.mock.calls[0][0]
expect(singleCallArgs).toEqual(expect.objectContaining({ converter_types: ['Base64Converter'] }))
expect(singleCallArgs).not.toHaveProperty('converter_types_match')

unmount()
jest.clearAllMocks()
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })

// Case 2: two converters → converter_types_match IS sent
const multiFilters = {
...DEFAULT_HISTORY_FILTERS,
converter: ['Base64Converter', 'ROT13Converter'],
converterMatchMode: 'all' as const,
}

render(
<TestWrapper>
<AttackHistory {...defaultProps} filters={multiFilters} />
</TestWrapper>
)

await waitFor(() => {
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
})

expect(mockedAttacksApi.listAttacks).toHaveBeenCalledWith(
expect.objectContaining({
converter_types: ['Base64Converter', 'ROT13Converter'],
converter_types_match: 'all',
})
)
})
})
46 changes: 30 additions & 16 deletions frontend/src/components/History/AttackHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
const [error, setError] = useState<string | null>(null)

// Filter options
const [attackClassOptions, setAttackClassOptions] = useState<string[]>([])
const [attackTypeOptions, setAttackTypeOptions] = useState<string[]>([])
const [converterOptions, setConverterOptions] = useState<string[]>([])
const [operatorOptions, setOperatorOptions] = useState<string[]>([])
const [operationOptions, setOperationOptions] = useState<string[]>([])
Expand All @@ -47,18 +47,22 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
setError(null)
try {
const labelParams: string[] = []
if (filters.operator) { labelParams.push(`operator:${filters.operator}`) }
if (filters.operation) { labelParams.push(`operation:${filters.operation}`) }
for (const op of filters.operator) { labelParams.push(`operator:${op}`) }
for (const op of filters.operation) { labelParams.push(`operation:${op}`) }
labelParams.push(...filters.otherLabels)

const response = await attacksApi.listAttacks({
limit: PAGE_SIZE,
...(pageCursor && { cursor: pageCursor }),
...(filters.attackClass && { attack_type: filters.attackClass }),
...(filters.outcome && { outcome: filters.outcome }),
...(filters.converter && { converter_types: [filters.converter] }),
...(labelParams.length > 0 && { label: labelParams }),
})
// Build request params; set each field only when the filter is active.
const params: Parameters<typeof attacksApi.listAttacks>[0] = { limit: PAGE_SIZE }
if (pageCursor) params.cursor = pageCursor
if (filters.attackTypes.length > 0) params.attack_types = filters.attackTypes
if (filters.outcome) params.outcome = filters.outcome
if (filters.converter.length > 0) params.converter_types = filters.converter
// Match mode is only meaningful with >=2 converters selected.
if (filters.converter.length >= 2) params.converter_types_match = filters.converterMatchMode
if (filters.hasConverters !== undefined) params.has_converters = filters.hasConverters
if (labelParams.length > 0) params.label = labelParams

const response = await attacksApi.listAttacks(params)
setAttacks(response.items.map(attack => ({ ...attack, labels: attack.labels ?? {} })))
setIsLastPage(!response.pagination.has_more)
setCursor(response.pagination.next_cursor ?? undefined)
Expand All @@ -68,12 +72,21 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
} finally {
setLoading(false)
}
}, [filters.attackClass, filters.outcome, filters.converter, filters.operator, filters.operation, filters.otherLabels])
}, [
filters.attackTypes,
filters.outcome,
filters.converter,
filters.converterMatchMode,
filters.hasConverters,
filters.operator,
filters.operation,
filters.otherLabels,
])

// Load filter options on mount
useEffect(() => {
attacksApi.getAttackOptions()
.then(resp => setAttackClassOptions(resp.attack_types))
.then(resp => setAttackTypeOptions(resp.attack_types))
.catch(() => { /* ignore */ })
attacksApi.getConverterOptions()
.then(resp => setConverterOptions(resp.converter_types))
Expand Down Expand Up @@ -132,8 +145,9 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
}

const hasActiveFilters =
filters.attackClass || filters.outcome || filters.converter ||
filters.operator || filters.operation || filters.otherLabels.length > 0
filters.attackTypes.length > 0 || filters.outcome || filters.converter.length > 0 ||
filters.hasConverters !== undefined ||
filters.operator.length > 0 || filters.operation.length > 0 || filters.otherLabels.length > 0

return (
<div className={styles.root}>
Expand All @@ -153,7 +167,7 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
<HistoryFiltersBar
filters={filters}
onFiltersChange={onFiltersChange}
attackClassOptions={attackClassOptions}
attackTypeOptions={attackTypeOptions}
converterOptions={converterOptions}
operatorOptions={operatorOptions}
operationOptions={operationOptions}
Expand Down
Loading
Loading