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
30 changes: 19 additions & 11 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
// Open the attack class combobox. Multiselect Combobox renders items as
// role="menuitemcheckbox", not role="option".
const dropdown = page.getByTestId("attack-class-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
137 changes: 131 additions & 6 deletions frontend/src/components/History/AttackHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ describe('AttackHistory', () => {
fireEvent.click(screen.getByText('CrescendoAttack'))

expect(onFiltersChange).toHaveBeenCalledWith(
expect.objectContaining({ attackClass: 'CrescendoAttack' })
expect.objectContaining({ attackClasses: ['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',
attackClasses: ['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 attackClasses 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,
attackClasses: ['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',
})
)
})
})
26 changes: 19 additions & 7 deletions frontend/src/components/History/AttackHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,18 @@ 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.attackClasses.length > 0 && { attack_types: filters.attackClasses }),
...(filters.outcome && { outcome: filters.outcome }),
...(filters.converter && { converter_types: [filters.converter] }),
...(filters.converter.length > 0 && { converter_types: filters.converter }),
...(filters.converter.length >= 2 && { converter_types_match: filters.converterMatchMode }),
...(filters.hasConverters !== undefined && { has_converters: filters.hasConverters }),
...(labelParams.length > 0 && { label: labelParams }),
})
setAttacks(response.items.map(attack => ({ ...attack, labels: attack.labels ?? {} })))
Expand All @@ -68,7 +70,16 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
} finally {
setLoading(false)
}
}, [filters.attackClass, filters.outcome, filters.converter, filters.operator, filters.operation, filters.otherLabels])
}, [
filters.attackClasses,
filters.outcome,
filters.converter,
filters.converterMatchMode,
filters.hasConverters,
filters.operator,
filters.operation,
filters.otherLabels,
])

// Load filter options on mount
useEffect(() => {
Expand Down Expand Up @@ -132,8 +143,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.attackClasses.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 Down
Loading
Loading