Skip to content

Commit b86d92d

Browse files
adrian-gavrilaAdrian GavrilaCopilot
authored
FEAT: Searchable multi-select filters for Attack History (ADO 7834) (#1643)
Co-authored-by: Adrian Gavrila <agavrila@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 017ebbd commit b86d92d

17 files changed

Lines changed: 1521 additions & 250 deletions

frontend/e2e/history.spec.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,23 +161,29 @@ async function mockHistoryAPIs(
161161
return;
162162
}
163163
const url = new URL(route.request().url());
164-
const attackType = url.searchParams.get("attack_type");
164+
const attackTypeParams = url.searchParams.getAll("attack_types");
165165
const outcome = url.searchParams.get("outcome");
166166
const labelParams = url.searchParams.getAll("label");
167167

168168
let filtered = [...attacks];
169-
if (attackType) {
170-
filtered = filtered.filter((a) => a.attack_type === attackType);
169+
if (attackTypeParams.length > 0) {
170+
filtered = filtered.filter((a) => attackTypeParams.includes(a.attack_type));
171171
}
172172
if (outcome) {
173173
filtered = filtered.filter((a) => a.outcome === outcome);
174174
}
175175
if (labelParams.length > 0) {
176+
// Group repeated label keys into OR-sets; combine across keys with AND.
177+
const grouped = new Map<string, string[]>();
178+
for (const lp of labelParams) {
179+
const [key, val] = lp.split(":");
180+
if (!key) continue;
181+
const bucket = grouped.get(key) ?? [];
182+
bucket.push(val ?? "");
183+
grouped.set(key, bucket);
184+
}
176185
filtered = filtered.filter((a) =>
177-
labelParams.every((lp) => {
178-
const [key, val] = lp.split(":");
179-
return a.labels[key] === val;
180-
}),
186+
Array.from(grouped.entries()).every(([key, vals]) => vals.includes(a.labels[key] ?? "")),
181187
);
182188
}
183189

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

213-
// Open the attack class dropdown and select SingleTurnAttack
214-
const dropdown = page.getByTestId("attack-class-filter");
219+
// Open the attack type combobox. Multiselect Combobox renders items as
220+
// role="menuitemcheckbox", not role="option".
221+
const dropdown = page.getByTestId("attack-type-filter");
215222
await dropdown.click();
216-
await page.getByRole("option", { name: "SingleTurnAttack" }).click();
223+
await page.getByRole("menuitemcheckbox", { name: "SingleTurnAttack" }).click();
217224

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

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

251259
// Only bob's attacks
252260
await expect(page.getByTestId("attack-row-atk-bob-b")).toBeVisible({ timeout: 5_000 });

frontend/src/components/History/AttackHistory.styles.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ export const useAttackHistoryStyles = makeStyles({
6969
gap: tokens.spacingHorizontalXXS,
7070
flexWrap: 'wrap',
7171
},
72+
matchModeToggle: {
73+
display: 'inline-flex',
74+
alignItems: 'center',
75+
gap: tokens.spacingHorizontalXS,
76+
paddingInline: tokens.spacingHorizontalXS,
77+
},
78+
matchModeLabel: {
79+
fontSize: tokens.fontSizeBase200,
80+
color: tokens.colorNeutralForeground3,
81+
userSelect: 'none',
82+
},
83+
matchModeLabelActive: {
84+
color: tokens.colorNeutralForeground1,
85+
fontWeight: tokens.fontWeightSemibold,
86+
},
7287
pagination: {
7388
display: 'flex',
7489
justifyContent: 'center',

frontend/src/components/History/AttackHistory.test.tsx

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe('AttackHistory', () => {
8383

8484
expect(screen.getByText('Attack History')).toBeInTheDocument()
8585
expect(screen.getByTestId('refresh-btn')).toBeInTheDocument()
86-
expect(screen.getByTestId('attack-class-filter')).toBeInTheDocument()
86+
expect(screen.getByTestId('attack-type-filter')).toBeInTheDocument()
8787
expect(screen.getByTestId('outcome-filter')).toBeInTheDocument()
8888
expect(screen.getByTestId('converter-filter')).toBeInTheDocument()
8989
expect(screen.getByTestId('operator-filter')).toBeInTheDocument()
@@ -656,7 +656,7 @@ describe('AttackHistory', () => {
656656
expect(screen.queryByTestId('reset-filters-btn')).not.toBeInTheDocument()
657657
})
658658

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

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

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

697697
expect(onFiltersChange).toHaveBeenCalledWith(
698-
expect.objectContaining({ attackClass: 'CrescendoAttack' })
698+
expect.objectContaining({ attackTypes: ['CrescendoAttack'] })
699699
)
700700
})
701701

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

769769
expect(onFiltersChange).toHaveBeenCalledWith(
770-
expect.objectContaining({ converter: 'Base64Converter' })
770+
expect.objectContaining({ converter: ['Base64Converter'] })
771771
)
772772
})
773773

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

810810
expect(onFiltersChange).toHaveBeenCalledWith(
811-
expect.objectContaining({ operator: 'alice' })
811+
expect.objectContaining({ operator: ['alice'] })
812812
)
813813
})
814814

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

851851
expect(onFiltersChange).toHaveBeenCalledWith(
852-
expect.objectContaining({ operation: 'op_alpha' })
852+
expect.objectContaining({ operation: ['op_alpha'] })
853853
)
854854
})
855855

@@ -900,9 +900,9 @@ describe('AttackHistory', () => {
900900
const onFiltersChange = jest.fn()
901901
const activeFilters = {
902902
...DEFAULT_HISTORY_FILTERS,
903-
attackClass: 'CrescendoAttack',
903+
attackTypes: ['CrescendoAttack'],
904904
outcome: 'success',
905-
operator: 'alice',
905+
operator: ['alice'],
906906
}
907907

908908
render(
@@ -961,4 +961,129 @@ describe('AttackHistory', () => {
961961
expect.objectContaining({ otherLabels: expect.any(Array), labelSearchText: '' })
962962
)
963963
})
964+
965+
it('should forward multi-select attackTypes as attack_types array to the API', async () => {
966+
mockedAttacksApi.listAttacks.mockResolvedValue({
967+
items: [],
968+
pagination: { limit: 25, has_more: false },
969+
})
970+
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
971+
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
972+
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })
973+
974+
const filters = {
975+
...DEFAULT_HISTORY_FILTERS,
976+
attackTypes: ['CrescendoAttack', 'RedTeamingAttack'],
977+
}
978+
979+
render(
980+
<TestWrapper>
981+
<AttackHistory {...defaultProps} filters={filters} />
982+
</TestWrapper>
983+
)
984+
985+
await waitFor(() => {
986+
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
987+
})
988+
989+
expect(mockedAttacksApi.listAttacks).toHaveBeenCalledWith(
990+
expect.objectContaining({ attack_types: ['CrescendoAttack', 'RedTeamingAttack'] })
991+
)
992+
})
993+
994+
it('should forward hasConverters=false without converter_types or converter_types_match', async () => {
995+
mockedAttacksApi.listAttacks.mockResolvedValue({
996+
items: [],
997+
pagination: { limit: 25, has_more: false },
998+
})
999+
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
1000+
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
1001+
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })
1002+
1003+
const filters = {
1004+
...DEFAULT_HISTORY_FILTERS,
1005+
hasConverters: false,
1006+
converter: [],
1007+
}
1008+
1009+
render(
1010+
<TestWrapper>
1011+
<AttackHistory {...defaultProps} filters={filters} />
1012+
</TestWrapper>
1013+
)
1014+
1015+
await waitFor(() => {
1016+
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
1017+
})
1018+
1019+
const callArgs = mockedAttacksApi.listAttacks.mock.calls[0][0]
1020+
expect(callArgs).toEqual(expect.objectContaining({ has_converters: false }))
1021+
expect(callArgs).not.toHaveProperty('converter_types')
1022+
expect(callArgs).not.toHaveProperty('converter_types_match')
1023+
})
1024+
1025+
it('should only send converter_types_match when two or more converters are selected', async () => {
1026+
mockedAttacksApi.listAttacks.mockResolvedValue({
1027+
items: [],
1028+
pagination: { limit: 25, has_more: false },
1029+
})
1030+
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
1031+
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
1032+
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })
1033+
1034+
// Case 1: single converter → converter_types_match is NOT sent
1035+
const singleFilters = {
1036+
...DEFAULT_HISTORY_FILTERS,
1037+
converter: ['Base64Converter'],
1038+
converterMatchMode: 'all' as const,
1039+
}
1040+
1041+
const { unmount } = render(
1042+
<TestWrapper>
1043+
<AttackHistory {...defaultProps} filters={singleFilters} />
1044+
</TestWrapper>
1045+
)
1046+
1047+
await waitFor(() => {
1048+
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
1049+
})
1050+
1051+
const singleCallArgs = mockedAttacksApi.listAttacks.mock.calls[0][0]
1052+
expect(singleCallArgs).toEqual(expect.objectContaining({ converter_types: ['Base64Converter'] }))
1053+
expect(singleCallArgs).not.toHaveProperty('converter_types_match')
1054+
1055+
unmount()
1056+
jest.clearAllMocks()
1057+
mockedAttacksApi.listAttacks.mockResolvedValue({
1058+
items: [],
1059+
pagination: { limit: 25, has_more: false },
1060+
})
1061+
mockedAttacksApi.getAttackOptions.mockResolvedValue({ attack_types: [] })
1062+
mockedAttacksApi.getConverterOptions.mockResolvedValue({ converter_types: [] })
1063+
mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: {} })
1064+
1065+
// Case 2: two converters → converter_types_match IS sent
1066+
const multiFilters = {
1067+
...DEFAULT_HISTORY_FILTERS,
1068+
converter: ['Base64Converter', 'ROT13Converter'],
1069+
converterMatchMode: 'all' as const,
1070+
}
1071+
1072+
render(
1073+
<TestWrapper>
1074+
<AttackHistory {...defaultProps} filters={multiFilters} />
1075+
</TestWrapper>
1076+
)
1077+
1078+
await waitFor(() => {
1079+
expect(mockedAttacksApi.listAttacks).toHaveBeenCalled()
1080+
})
1081+
1082+
expect(mockedAttacksApi.listAttacks).toHaveBeenCalledWith(
1083+
expect.objectContaining({
1084+
converter_types: ['Base64Converter', 'ROT13Converter'],
1085+
converter_types_match: 'all',
1086+
})
1087+
)
1088+
})
9641089
})

frontend/src/components/History/AttackHistory.tsx

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
2929
const [error, setError] = useState<string | null>(null)
3030

3131
// Filter options
32-
const [attackClassOptions, setAttackClassOptions] = useState<string[]>([])
32+
const [attackTypeOptions, setAttackTypeOptions] = useState<string[]>([])
3333
const [converterOptions, setConverterOptions] = useState<string[]>([])
3434
const [operatorOptions, setOperatorOptions] = useState<string[]>([])
3535
const [operationOptions, setOperationOptions] = useState<string[]>([])
@@ -47,18 +47,22 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
4747
setError(null)
4848
try {
4949
const labelParams: string[] = []
50-
if (filters.operator) { labelParams.push(`operator:${filters.operator}`) }
51-
if (filters.operation) { labelParams.push(`operation:${filters.operation}`) }
50+
for (const op of filters.operator) { labelParams.push(`operator:${op}`) }
51+
for (const op of filters.operation) { labelParams.push(`operation:${op}`) }
5252
labelParams.push(...filters.otherLabels)
5353

54-
const response = await attacksApi.listAttacks({
55-
limit: PAGE_SIZE,
56-
...(pageCursor && { cursor: pageCursor }),
57-
...(filters.attackClass && { attack_type: filters.attackClass }),
58-
...(filters.outcome && { outcome: filters.outcome }),
59-
...(filters.converter && { converter_types: [filters.converter] }),
60-
...(labelParams.length > 0 && { label: labelParams }),
61-
})
54+
// Build request params; set each field only when the filter is active.
55+
const params: Parameters<typeof attacksApi.listAttacks>[0] = { limit: PAGE_SIZE }
56+
if (pageCursor) params.cursor = pageCursor
57+
if (filters.attackTypes.length > 0) params.attack_types = filters.attackTypes
58+
if (filters.outcome) params.outcome = filters.outcome
59+
if (filters.converter.length > 0) params.converter_types = filters.converter
60+
// Match mode is only meaningful with >=2 converters selected.
61+
if (filters.converter.length >= 2) params.converter_types_match = filters.converterMatchMode
62+
if (filters.hasConverters !== undefined) params.has_converters = filters.hasConverters
63+
if (labelParams.length > 0) params.label = labelParams
64+
65+
const response = await attacksApi.listAttacks(params)
6266
setAttacks(response.items.map(attack => ({ ...attack, labels: attack.labels ?? {} })))
6367
setIsLastPage(!response.pagination.has_more)
6468
setCursor(response.pagination.next_cursor ?? undefined)
@@ -68,12 +72,21 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
6872
} finally {
6973
setLoading(false)
7074
}
71-
}, [filters.attackClass, filters.outcome, filters.converter, filters.operator, filters.operation, filters.otherLabels])
75+
}, [
76+
filters.attackTypes,
77+
filters.outcome,
78+
filters.converter,
79+
filters.converterMatchMode,
80+
filters.hasConverters,
81+
filters.operator,
82+
filters.operation,
83+
filters.otherLabels,
84+
])
7285

7386
// Load filter options on mount
7487
useEffect(() => {
7588
attacksApi.getAttackOptions()
76-
.then(resp => setAttackClassOptions(resp.attack_types))
89+
.then(resp => setAttackTypeOptions(resp.attack_types))
7790
.catch(() => { /* ignore */ })
7891
attacksApi.getConverterOptions()
7992
.then(resp => setConverterOptions(resp.converter_types))
@@ -132,8 +145,9 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
132145
}
133146

134147
const hasActiveFilters =
135-
filters.attackClass || filters.outcome || filters.converter ||
136-
filters.operator || filters.operation || filters.otherLabels.length > 0
148+
filters.attackTypes.length > 0 || filters.outcome || filters.converter.length > 0 ||
149+
filters.hasConverters !== undefined ||
150+
filters.operator.length > 0 || filters.operation.length > 0 || filters.otherLabels.length > 0
137151

138152
return (
139153
<div className={styles.root}>
@@ -153,7 +167,7 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
153167
<HistoryFiltersBar
154168
filters={filters}
155169
onFiltersChange={onFiltersChange}
156-
attackClassOptions={attackClassOptions}
170+
attackTypeOptions={attackTypeOptions}
157171
converterOptions={converterOptions}
158172
operatorOptions={operatorOptions}
159173
operationOptions={operationOptions}

0 commit comments

Comments
 (0)