Skip to content

Commit c06c972

Browse files
authored
[Security Solution][Exceptions] - Updates exception hooks and viewer (#73588) (#73748)
## Summary This PR focuses on addressing issues around the pagination and functionality of rules with numerous (2+) exception lists. - Updated the `use_exception_list.ts` hook to make use of the new multi list find API - Updated the viewer to make use of the new multi list find API - Previously was doing a lot of the filtering and paging manually (and badly) in the UI, now the _find takes care of all that - Added logic for showing `No results` text if user filter/search returns no items - Previously would show the `This rule has not exceptions` text
1 parent 2a5b3ed commit c06c972

File tree

23 files changed

+1114
-423
lines changed

23 files changed

+1114
-423
lines changed

x-pack/plugins/lists/public/exceptions/api.test.ts

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
deleteExceptionListItemById,
2727
fetchExceptionListById,
2828
fetchExceptionListItemById,
29-
fetchExceptionListItemsByListId,
29+
fetchExceptionListsItemsByListIds,
3030
updateExceptionList,
3131
updateExceptionListItem,
3232
} from './api';
@@ -358,17 +358,18 @@ describe('Exceptions Lists API', () => {
358358
});
359359
});
360360

361-
describe('#fetchExceptionListItemsByListId', () => {
361+
describe('#fetchExceptionListsItemsByListIds', () => {
362362
beforeEach(() => {
363363
fetchMock.mockClear();
364364
fetchMock.mockResolvedValue(getFoundExceptionListItemSchemaMock());
365365
});
366366

367-
test('it invokes "fetchExceptionListItemsByListId" with expected url and body values', async () => {
368-
await fetchExceptionListItemsByListId({
367+
test('it invokes "fetchExceptionListsItemsByListIds" with expected url and body values', async () => {
368+
await fetchExceptionListsItemsByListIds({
369+
filterOptions: [],
369370
http: mockKibanaHttpService(),
370-
listId: 'myList',
371-
namespaceType: 'single',
371+
listIds: ['myList', 'myOtherListId'],
372+
namespaceTypes: ['single', 'single'],
372373
pagination: {
373374
page: 1,
374375
perPage: 20,
@@ -379,8 +380,8 @@ describe('Exceptions Lists API', () => {
379380
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
380381
method: 'GET',
381382
query: {
382-
list_id: 'myList',
383-
namespace_type: 'single',
383+
list_id: 'myList,myOtherListId',
384+
namespace_type: 'single,single',
384385
page: '1',
385386
per_page: '20',
386387
},
@@ -389,14 +390,16 @@ describe('Exceptions Lists API', () => {
389390
});
390391

391392
test('it invokes with expected url and body values when a filter exists and "namespaceType" of "single"', async () => {
392-
await fetchExceptionListItemsByListId({
393-
filterOptions: {
394-
filter: 'hello world',
395-
tags: [],
396-
},
393+
await fetchExceptionListsItemsByListIds({
394+
filterOptions: [
395+
{
396+
filter: 'hello world',
397+
tags: [],
398+
},
399+
],
397400
http: mockKibanaHttpService(),
398-
listId: 'myList',
399-
namespaceType: 'single',
401+
listIds: ['myList'],
402+
namespaceTypes: ['single'],
400403
pagination: {
401404
page: 1,
402405
perPage: 20,
@@ -418,14 +421,16 @@ describe('Exceptions Lists API', () => {
418421
});
419422

420423
test('it invokes with expected url and body values when a filter exists and "namespaceType" of "agnostic"', async () => {
421-
await fetchExceptionListItemsByListId({
422-
filterOptions: {
423-
filter: 'hello world',
424-
tags: [],
425-
},
424+
await fetchExceptionListsItemsByListIds({
425+
filterOptions: [
426+
{
427+
filter: 'hello world',
428+
tags: [],
429+
},
430+
],
426431
http: mockKibanaHttpService(),
427-
listId: 'myList',
428-
namespaceType: 'agnostic',
432+
listIds: ['myList'],
433+
namespaceTypes: ['agnostic'],
429434
pagination: {
430435
page: 1,
431436
perPage: 20,
@@ -447,14 +452,16 @@ describe('Exceptions Lists API', () => {
447452
});
448453

449454
test('it invokes with expected url and body values when tags exists', async () => {
450-
await fetchExceptionListItemsByListId({
451-
filterOptions: {
452-
filter: '',
453-
tags: ['malware'],
454-
},
455+
await fetchExceptionListsItemsByListIds({
456+
filterOptions: [
457+
{
458+
filter: '',
459+
tags: ['malware'],
460+
},
461+
],
455462
http: mockKibanaHttpService(),
456-
listId: 'myList',
457-
namespaceType: 'agnostic',
463+
listIds: ['myList'],
464+
namespaceTypes: ['agnostic'],
458465
pagination: {
459466
page: 1,
460467
perPage: 20,
@@ -476,14 +483,16 @@ describe('Exceptions Lists API', () => {
476483
});
477484

478485
test('it invokes with expected url and body values when filter and tags exists', async () => {
479-
await fetchExceptionListItemsByListId({
480-
filterOptions: {
481-
filter: 'host.name',
482-
tags: ['malware'],
483-
},
486+
await fetchExceptionListsItemsByListIds({
487+
filterOptions: [
488+
{
489+
filter: 'host.name',
490+
tags: ['malware'],
491+
},
492+
],
484493
http: mockKibanaHttpService(),
485-
listId: 'myList',
486-
namespaceType: 'agnostic',
494+
listIds: ['myList'],
495+
namespaceTypes: ['agnostic'],
487496
pagination: {
488497
page: 1,
489498
perPage: 20,
@@ -506,10 +515,11 @@ describe('Exceptions Lists API', () => {
506515
});
507516

508517
test('it returns expected format when call succeeds', async () => {
509-
const exceptionResponse = await fetchExceptionListItemsByListId({
518+
const exceptionResponse = await fetchExceptionListsItemsByListIds({
519+
filterOptions: [],
510520
http: mockKibanaHttpService(),
511-
listId: 'endpoint_list_id',
512-
namespaceType: 'single',
521+
listIds: ['endpoint_list_id'],
522+
namespaceTypes: ['single'],
513523
pagination: {
514524
page: 1,
515525
perPage: 20,
@@ -521,16 +531,17 @@ describe('Exceptions Lists API', () => {
521531

522532
test('it returns error and does not make request if request payload fails decode', async () => {
523533
const payload = ({
534+
filterOptions: [],
524535
http: mockKibanaHttpService(),
525-
listId: '1',
526-
namespaceType: 'not a namespace type',
536+
listIds: ['myList'],
537+
namespaceTypes: ['not a namespace type'],
527538
pagination: {
528539
page: 1,
529540
perPage: 20,
530541
},
531542
signal: abortCtrl.signal,
532543
} as unknown) as ApiCallByListIdProps & { listId: number };
533-
await expect(fetchExceptionListItemsByListId(payload)).rejects.toEqual(
544+
await expect(fetchExceptionListsItemsByListIds(payload)).rejects.toEqual(
534545
'Invalid value "not a namespace type" supplied to "namespace_type"'
535546
);
536547
});
@@ -541,10 +552,11 @@ describe('Exceptions Lists API', () => {
541552
fetchMock.mockResolvedValue(badPayload);
542553

543554
await expect(
544-
fetchExceptionListItemsByListId({
555+
fetchExceptionListsItemsByListIds({
556+
filterOptions: [],
545557
http: mockKibanaHttpService(),
546-
listId: 'myList',
547-
namespaceType: 'single',
558+
listIds: ['myList'],
559+
namespaceTypes: ['single'],
548560
pagination: {
549561
page: 1,
550562
perPage: 20,

x-pack/plugins/lists/public/exceptions/api.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -249,42 +249,46 @@ export const fetchExceptionListById = async ({
249249
* Fetch an ExceptionList's ExceptionItems by providing a ExceptionList list_id
250250
*
251251
* @param http Kibana http service
252-
* @param listId ExceptionList list_id (not ID)
253-
* @param namespaceType ExceptionList namespace_type
252+
* @param listIds ExceptionList list_ids (not ID)
253+
* @param namespaceTypes ExceptionList namespace_types
254254
* @param filterOptions optional - filter by field or tags
255255
* @param pagination optional
256256
* @param signal to cancel request
257257
*
258258
* @throws An error if response is not OK
259259
*/
260-
export const fetchExceptionListItemsByListId = async ({
260+
export const fetchExceptionListsItemsByListIds = async ({
261261
http,
262-
listId,
263-
namespaceType,
264-
filterOptions = {
265-
filter: '',
266-
tags: [],
267-
},
262+
listIds,
263+
namespaceTypes,
264+
filterOptions,
268265
pagination,
269266
signal,
270267
}: ApiCallByListIdProps): Promise<FoundExceptionListItemSchema> => {
271-
const namespace =
272-
namespaceType === 'agnostic' ? EXCEPTION_LIST_NAMESPACE_AGNOSTIC : EXCEPTION_LIST_NAMESPACE;
273-
const filters = [
274-
...(filterOptions.filter.length
275-
? [`${namespace}.attributes.entries.field:${filterOptions.filter}*`]
276-
: []),
277-
...(filterOptions.tags.length
278-
? filterOptions.tags.map((t) => `${namespace}.attributes.tags:${t}`)
279-
: []),
280-
];
268+
const filters: string = filterOptions
269+
.map<string>((filter, index) => {
270+
const namespace = namespaceTypes[index];
271+
const filterNamespace =
272+
namespace === 'agnostic' ? EXCEPTION_LIST_NAMESPACE_AGNOSTIC : EXCEPTION_LIST_NAMESPACE;
273+
const formattedFilters = [
274+
...(filter.filter.length
275+
? [`${filterNamespace}.attributes.entries.field:${filter.filter}*`]
276+
: []),
277+
...(filter.tags.length
278+
? filter.tags.map((t) => `${filterNamespace}.attributes.tags:${t}`)
279+
: []),
280+
];
281+
282+
return formattedFilters.join(' AND ');
283+
})
284+
.join(',');
281285

282286
const query = {
283-
list_id: listId,
284-
namespace_type: namespaceType,
287+
list_id: listIds.join(','),
288+
namespace_type: namespaceTypes.join(','),
285289
page: pagination.page ? `${pagination.page}` : '1',
286290
per_page: pagination.perPage ? `${pagination.perPage}` : '20',
287-
...(filters.length ? { filter: filters.join(' AND ') } : {}),
291+
...(filters.trim() !== '' ? { filter: filters } : {}),
288292
};
289293
const [validatedRequest, errorsRequest] = validate(query, findExceptionListItemSchema);
290294

x-pack/plugins/lists/public/exceptions/hooks/use_api.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { act, renderHook } from '@testing-library/react-hooks';
99
import * as api from '../api';
1010
import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core';
1111
import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock';
12+
import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock';
1213
import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock';
1314
import { HttpStart } from '../../../../../../src/core/public';
14-
import { ApiCallByIdProps } from '../types';
15+
import { ApiCallByIdProps, ApiCallByListIdProps } from '../types';
1516

1617
import { ExceptionsApi, useApi } from './use_api';
1718

@@ -252,4 +253,116 @@ describe('useApi', () => {
252253
expect(onErrorMock).toHaveBeenCalledWith(mockError);
253254
});
254255
});
256+
257+
test('it invokes "fetchExceptionListsItemsByListIds" when "getExceptionItem" used', async () => {
258+
const output = getFoundExceptionListItemSchemaMock();
259+
const onSuccessMock = jest.fn();
260+
const spyOnFetchExceptionListsItemsByListIds = jest
261+
.spyOn(api, 'fetchExceptionListsItemsByListIds')
262+
.mockResolvedValue(output);
263+
264+
await act(async () => {
265+
const { result, waitForNextUpdate } = renderHook<HttpStart, ExceptionsApi>(() =>
266+
useApi(mockKibanaHttpService)
267+
);
268+
await waitForNextUpdate();
269+
270+
await result.current.getExceptionListsItems({
271+
filterOptions: [],
272+
lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }],
273+
onError: jest.fn(),
274+
onSuccess: onSuccessMock,
275+
pagination: {
276+
page: 1,
277+
perPage: 20,
278+
total: 0,
279+
},
280+
showDetectionsListsOnly: false,
281+
showEndpointListsOnly: false,
282+
});
283+
284+
const expected: ApiCallByListIdProps = {
285+
filterOptions: [],
286+
http: mockKibanaHttpService,
287+
listIds: ['list_id'],
288+
namespaceTypes: ['single'],
289+
pagination: {
290+
page: 1,
291+
perPage: 20,
292+
total: 0,
293+
},
294+
signal: new AbortController().signal,
295+
};
296+
297+
expect(spyOnFetchExceptionListsItemsByListIds).toHaveBeenCalledWith(expected);
298+
expect(onSuccessMock).toHaveBeenCalled();
299+
});
300+
});
301+
302+
test('it does not invoke "fetchExceptionListsItemsByListIds" if no listIds', async () => {
303+
const output = getFoundExceptionListItemSchemaMock();
304+
const onSuccessMock = jest.fn();
305+
const spyOnFetchExceptionListsItemsByListIds = jest
306+
.spyOn(api, 'fetchExceptionListsItemsByListIds')
307+
.mockResolvedValue(output);
308+
309+
await act(async () => {
310+
const { result, waitForNextUpdate } = renderHook<HttpStart, ExceptionsApi>(() =>
311+
useApi(mockKibanaHttpService)
312+
);
313+
await waitForNextUpdate();
314+
315+
await result.current.getExceptionListsItems({
316+
filterOptions: [],
317+
lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }],
318+
onError: jest.fn(),
319+
onSuccess: onSuccessMock,
320+
pagination: {
321+
page: 1,
322+
perPage: 20,
323+
total: 0,
324+
},
325+
showDetectionsListsOnly: false,
326+
showEndpointListsOnly: true,
327+
});
328+
329+
expect(spyOnFetchExceptionListsItemsByListIds).not.toHaveBeenCalled();
330+
expect(onSuccessMock).toHaveBeenCalledWith({
331+
exceptions: [],
332+
pagination: {
333+
page: 0,
334+
perPage: 20,
335+
total: 0,
336+
},
337+
});
338+
});
339+
});
340+
341+
test('invokes "onError" callback if "fetchExceptionListsItemsByListIds" fails', async () => {
342+
const mockError = new Error('failed to delete item');
343+
jest.spyOn(api, 'fetchExceptionListsItemsByListIds').mockRejectedValue(mockError);
344+
345+
await act(async () => {
346+
const { result, waitForNextUpdate } = renderHook<HttpStart, ExceptionsApi>(() =>
347+
useApi(mockKibanaHttpService)
348+
);
349+
await waitForNextUpdate();
350+
351+
await result.current.getExceptionListsItems({
352+
filterOptions: [],
353+
lists: [{ id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }],
354+
onError: onErrorMock,
355+
onSuccess: jest.fn(),
356+
pagination: {
357+
page: 1,
358+
perPage: 20,
359+
total: 0,
360+
},
361+
showDetectionsListsOnly: false,
362+
showEndpointListsOnly: false,
363+
});
364+
365+
expect(onErrorMock).toHaveBeenCalledWith(mockError);
366+
});
367+
});
255368
});

0 commit comments

Comments
 (0)