[SIEM][Exceptions] - Update exceptions hooks to include _find filtering (#67435)

### Summary 

- Updates exception list hooks to include filtering options and updates corresponding unit tests.
- Adds refreshList callback to hook that fetches the list and its items
- Updates hooks tests to test onError callback
- Updates tests to use type checking more effectively per feedback from @FrankHassanabad (thanks!)
This commit is contained in:
Yara Tercero 2020-06-01 14:32:42 -04:00 committed by GitHub
parent cdbcb9720b
commit 279b11b78d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 630 additions and 199 deletions

View file

@ -29,3 +29,13 @@ export const TYPE = 'ip';
export const VALUE = '127.0.0.1';
export const VALUE_2 = '255.255.255';
export const NAMESPACE_TYPE = 'single';
// Exception List specific
export const ENDPOINT_TYPE = 'endpoint';
export const ENTRIES = [
{ field: 'some.field', match: 'some value', match_any: undefined, operator: 'included' },
];
export const ITEM_TYPE = 'simple';
export const _TAGS = [];
export const TAGS = [];
export const COMMENT = [];

View file

@ -16,3 +16,9 @@ export const LIST_ITEM_URL = `${LIST_URL}/items`;
*/
export const EXCEPTION_LIST_URL = '/api/exception_lists';
export const EXCEPTION_LIST_ITEM_URL = '/api/exception_lists/items';
/**
* Exception list spaces
*/
export const EXCEPTION_LIST_NAMESPACE_AGNOSTIC = 'exception-list-agnostic';
export const EXCEPTION_LIST_NAMESPACE = 'exception-list';

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
COMMENT,
DESCRIPTION,
ENTRIES,
ITEM_TYPE,
LIST_ID,
META,
NAME,
NAMESPACE_TYPE,
TAGS,
_TAGS,
} from '../../constants.mock';
import { CreateExceptionListItemSchema } from './create_exception_list_item_schema';
export const getCreateExceptionListItemSchemaMock = (): CreateExceptionListItemSchema => ({
_tags: _TAGS,
comment: COMMENT,
description: DESCRIPTION,
entries: ENTRIES,
item_id: undefined,
list_id: LIST_ID,
meta: META,
name: NAME,
namespace_type: NAMESPACE_TYPE,
tags: TAGS,
type: ITEM_TYPE,
});

View file

@ -40,8 +40,10 @@ export const fetchExceptionListById = async ({
}: ApiCallByIdProps): Promise<ExceptionListSchema> => Promise.resolve(getExceptionListSchemaMock());
export const fetchExceptionListItemsByListId = async ({
filterOptions,
http,
listId,
pagination,
signal,
}: ApiCallByListIdProps): Promise<FoundExceptionListItemSchema> =>
Promise.resolve({ data: [getExceptionListItemSchemaMock()], page: 1, per_page: 20, total: 1 });

View file

@ -6,8 +6,9 @@
import { createKibanaCoreStartMock } from '../common/mocks/kibana_core';
import { getExceptionListSchemaMock } from '../../common/schemas/response/exception_list_schema.mock';
import { getExceptionListItemSchemaMock } from '../../common/schemas/response/exception_list_item_schema.mock';
import { getCreateExceptionListSchemaMock } from '../../common/schemas/request/create_exception_list_schema.mock';
import { getCreateExceptionListItemSchemaMock } from '../../common/schemas/request/create_exception_list_item_schema.mock';
import { mockNewExceptionItem, mockNewExceptionList } from './mock';
import {
addExceptionList,
addExceptionListItem,
@ -37,188 +38,291 @@ const mockKibanaHttpService = ((createKibanaCoreStartMock() as unknown) as jest.
);
describe('Exceptions Lists API', () => {
describe('addExceptionList', () => {
describe('#addExceptionList', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(getExceptionListSchemaMock());
});
test('check parameter url, body', async () => {
await addExceptionList({
test('it uses POST when "list.id" does not exist', async () => {
const payload = getCreateExceptionListSchemaMock();
const exceptionResponse = await addExceptionList({
http: mockKibanaHttpService(),
list: mockNewExceptionList,
list: payload,
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', {
body:
'{"_tags":["endpoint","process","malware","os:linux"],"description":"This is a sample endpoint type exception","list_id":"endpoint_list","name":"Sample Endpoint Exception List","tags":["user added string for a tag","malware"],"type":"endpoint"}',
body: JSON.stringify(payload),
method: 'POST',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual({ id: '1', ...getExceptionListSchemaMock() });
});
test('check parameter url, body when "list.id" exists', async () => {
await addExceptionList({
test('it uses PUT when "list.id" exists', async () => {
const payload = getExceptionListSchemaMock();
const exceptionResponse = await addExceptionList({
http: mockKibanaHttpService(),
list: getExceptionListSchemaMock(),
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', {
body:
'{"_tags":["endpoint","process","malware","os:linux"],"created_at":"2020-04-23T00:19:13.289Z","created_by":"user_name","description":"This is a sample endpoint type exception","id":"1","list_id":"endpoint_list","meta":{},"name":"Sample Endpoint Exception List","namespace_type":"single","tags":["user added string for a tag","malware"],"tie_breaker_id":"77fd1909-6786-428a-a671-30229a719c1f","type":"endpoint","updated_at":"2020-04-23T00:19:13.289Z","updated_by":"user_name"}',
method: 'PUT',
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
const exceptionResponse = await addExceptionList({
http: mockKibanaHttpService(),
list: mockNewExceptionList,
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', {
body: JSON.stringify(payload),
method: 'PUT',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(getExceptionListSchemaMock());
});
});
describe('addExceptionListItem', () => {
describe('#addExceptionListItem', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(getExceptionListItemSchemaMock());
});
test('check parameter url, body', async () => {
await addExceptionListItem({
test('it uses POST when "listItem.id" does not exist', async () => {
const payload = getCreateExceptionListItemSchemaMock();
const exceptionResponse = await addExceptionListItem({
http: mockKibanaHttpService(),
listItem: mockNewExceptionItem,
listItem: payload,
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', {
body:
'{"_tags":["endpoint","process","malware","os:linux"],"description":"This is a sample endpoint type exception","entries":[{"field":"actingProcess.file.signer","match":"Elastic, N.V.","operator":"included"},{"field":"event.category","match_any":["process","malware"],"operator":"included"}],"item_id":"endpoint_list_item","list_id":"endpoint_list","name":"Sample Endpoint Exception List","tags":["user added string for a tag","malware"],"type":"simple"}',
body: JSON.stringify(payload),
method: 'POST',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock());
});
test('check parameter url, body when "listItem.id" exists', async () => {
await addExceptionListItem({
const payload = getExceptionListItemSchemaMock();
const exceptionResponse = await addExceptionListItem({
http: mockKibanaHttpService(),
listItem: getExceptionListItemSchemaMock(),
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', {
body:
'{"_tags":["endpoint","process","malware","os:linux"],"comment":[],"created_at":"2020-04-23T00:19:13.289Z","created_by":"user_name","description":"This is a sample endpoint type exception","entries":[{"field":"actingProcess.file.signer","match":"Elastic, N.V.","operator":"included"},{"field":"event.category","match_any":["process","malware"],"operator":"included"}],"id":"1","item_id":"endpoint_list_item","list_id":"endpoint_list","meta":{},"name":"Sample Endpoint Exception List","namespace_type":"single","tags":["user added string for a tag","malware"],"tie_breaker_id":"77fd1909-6786-428a-a671-30229a719c1f","type":"simple","updated_at":"2020-04-23T00:19:13.289Z","updated_by":"user_name"}',
method: 'PUT',
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
const exceptionResponse = await addExceptionListItem({
http: mockKibanaHttpService(),
listItem: mockNewExceptionItem,
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', {
body: JSON.stringify(payload),
method: 'PUT',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock());
});
});
describe('fetchExceptionListById', () => {
describe('#fetchExceptionListById', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(getExceptionListSchemaMock());
});
test('check parameter url, body', async () => {
test('it invokes "fetchExceptionListById" with expected url and body values', async () => {
await fetchExceptionListById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', {
method: 'GET',
query: {
id: '1',
namespace_type: 'single',
},
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
test('it returns expected exception list on success', async () => {
const exceptionResponse = await fetchExceptionListById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(getExceptionListSchemaMock());
});
});
describe('fetchExceptionListItemsByListId', () => {
describe('#fetchExceptionListItemsByListId', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue([mockNewExceptionItem]);
fetchMock.mockResolvedValue([getExceptionListItemSchemaMock()]);
});
test('check parameter url, body', async () => {
test('it invokes "fetchExceptionListItemsByListId" with expected url and body values', async () => {
await fetchExceptionListItemsByListId({
http: mockKibanaHttpService(),
listId: 'endpoint_list',
listId: 'myList',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
method: 'GET',
query: {
list_id: 'endpoint_list',
list_id: 'myList',
namespace_type: 'single',
page: 1,
per_page: 20,
},
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
test('it invokes with expected url and body values when a filter exists and "namespaceType" of "single"', async () => {
await fetchExceptionListItemsByListId({
filterOptions: {
filter: 'hello world',
tags: [],
},
http: mockKibanaHttpService(),
listId: 'myList',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
method: 'GET',
query: {
filter: 'exception-list.attributes.entries.field:hello world*',
list_id: 'myList',
namespace_type: 'single',
page: 1,
per_page: 20,
},
signal: abortCtrl.signal,
});
});
test('it invokes with expected url and body values when a filter exists and "namespaceType" of "agnostic"', async () => {
await fetchExceptionListItemsByListId({
filterOptions: {
filter: 'hello world',
tags: [],
},
http: mockKibanaHttpService(),
listId: 'myList',
namespaceType: 'agnostic',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
method: 'GET',
query: {
filter: 'exception-list-agnostic.attributes.entries.field:hello world*',
list_id: 'myList',
namespace_type: 'agnostic',
page: 1,
per_page: 20,
},
signal: abortCtrl.signal,
});
});
test('it invokes with expected url and body values when tags exists', async () => {
await fetchExceptionListItemsByListId({
filterOptions: {
filter: '',
tags: ['malware'],
},
http: mockKibanaHttpService(),
listId: 'myList',
namespaceType: 'agnostic',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
method: 'GET',
query: {
filter: 'exception-list-agnostic.attributes.tags:malware',
list_id: 'myList',
namespace_type: 'agnostic',
page: 1,
per_page: 20,
},
signal: abortCtrl.signal,
});
});
test('it invokes with expected url and body values when filter and tags exists', async () => {
await fetchExceptionListItemsByListId({
filterOptions: {
filter: 'host.name',
tags: ['malware'],
},
http: mockKibanaHttpService(),
listId: 'myList',
namespaceType: 'agnostic',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items/_find', {
method: 'GET',
query: {
filter:
'exception-list-agnostic.attributes.entries.field:host.name* AND exception-list-agnostic.attributes.tags:malware',
list_id: 'myList',
namespace_type: 'agnostic',
page: 1,
per_page: 20,
},
signal: abortCtrl.signal,
});
});
test('it returns expected format when call succeeds', async () => {
const exceptionResponse = await fetchExceptionListItemsByListId({
http: mockKibanaHttpService(),
listId: 'endpoint_list',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual([mockNewExceptionItem]);
expect(exceptionResponse).toEqual([getExceptionListItemSchemaMock()]);
});
});
describe('fetchExceptionListItemById', () => {
describe('#fetchExceptionListItemById', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue([mockNewExceptionItem]);
fetchMock.mockResolvedValue([getExceptionListItemSchemaMock()]);
});
test('check parameter url, body', async () => {
test('it invokes "fetchExceptionListItemById" with expected url and body values', async () => {
await fetchExceptionListItemById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', {
method: 'GET',
query: {
id: '1',
namespace_type: 'single',
},
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
test('it returns expected format when call succeeds', async () => {
const exceptionResponse = await fetchExceptionListItemById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual([mockNewExceptionItem]);
expect(exceptionResponse).toEqual([getExceptionListItemSchemaMock()]);
});
});
describe('deleteExceptionListById', () => {
describe('#deleteExceptionListById', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(getExceptionListSchemaMock());
@ -228,28 +332,31 @@ describe('Exceptions Lists API', () => {
await deleteExceptionListById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', {
method: 'DELETE',
query: {
id: '1',
namespace_type: 'single',
},
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
test('it returns expected format when call succeeds', async () => {
const exceptionResponse = await deleteExceptionListById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(getExceptionListSchemaMock());
});
});
describe('deleteExceptionListItemById', () => {
describe('#deleteExceptionListItemById', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(getExceptionListItemSchemaMock());
@ -259,21 +366,24 @@ describe('Exceptions Lists API', () => {
await deleteExceptionListItemById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', {
method: 'DELETE',
query: {
id: '1',
namespace_type: 'single',
},
signal: abortCtrl.signal,
});
});
test('happy path', async () => {
test('it returns expected format when call succeeds', async () => {
const exceptionResponse = await deleteExceptionListItemById({
http: mockKibanaHttpService(),
id: '1',
namespaceType: 'single',
signal: abortCtrl.signal,
});
expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock());

View file

@ -4,7 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '../../common/constants';
import {
EXCEPTION_LIST_ITEM_URL,
EXCEPTION_LIST_NAMESPACE,
EXCEPTION_LIST_NAMESPACE_AGNOSTIC,
EXCEPTION_LIST_URL,
} from '../../common/constants';
import {
ExceptionListItemSchema,
ExceptionListSchema,
@ -21,6 +26,7 @@ import {
/**
* Add provided ExceptionList
*
* @param http Kibana http service
* @param list exception list to add
* @param signal to cancel request
*
@ -43,6 +49,7 @@ export const addExceptionList = async ({
/**
* Add provided ExceptionListItem
*
* @param http Kibana http service
* @param listItem exception list item to add
* @param signal to cancel request
*
@ -65,7 +72,9 @@ export const addExceptionListItem = async ({
/**
* Fetch an ExceptionList by providing a ExceptionList ID
*
* @param http Kibana http service
* @param id ExceptionList ID (not list_id)
* @param namespaceType ExceptionList namespace_type
* @param signal to cancel request
*
* @throws An error if response is not OK
@ -73,18 +82,23 @@ export const addExceptionListItem = async ({
export const fetchExceptionListById = async ({
http,
id,
namespaceType,
signal,
}: ApiCallByIdProps): Promise<ExceptionListSchema> =>
http.fetch<ExceptionListSchema>(`${EXCEPTION_LIST_URL}`, {
method: 'GET',
query: { id },
query: { id, namespace_type: namespaceType },
signal,
});
/**
* Fetch an ExceptionList's ExceptionItems by providing a ExceptionList list_id
*
* @param id ExceptionList list_id (not ID)
* @param http Kibana http service
* @param listId ExceptionList list_id (not ID)
* @param namespaceType ExceptionList namespace_type
* @param filterOptions optional - filter by field or tags
* @param pagination optional
* @param signal to cancel request
*
* @throws An error if response is not OK
@ -92,18 +106,48 @@ export const fetchExceptionListById = async ({
export const fetchExceptionListItemsByListId = async ({
http,
listId,
namespaceType,
filterOptions = {
filter: '',
tags: [],
},
pagination = {
page: 1,
perPage: 20,
total: 0,
},
signal,
}: ApiCallByListIdProps): Promise<FoundExceptionListItemSchema> =>
http.fetch<FoundExceptionListItemSchema>(`${EXCEPTION_LIST_ITEM_URL}/_find`, {
}: ApiCallByListIdProps): Promise<FoundExceptionListItemSchema> => {
const namespace =
namespaceType === 'agnostic' ? EXCEPTION_LIST_NAMESPACE_AGNOSTIC : EXCEPTION_LIST_NAMESPACE;
const filters = [
...(filterOptions.filter.length
? [`${namespace}.attributes.entries.field:${filterOptions.filter}*`]
: []),
...(filterOptions.tags?.map((t) => `${namespace}.attributes.tags:${t}`) ?? []),
];
const query = {
list_id: listId,
namespace_type: namespaceType,
page: pagination.page,
per_page: pagination.perPage,
...(filters.length ? { filter: filters.join(' AND ') } : {}),
};
return http.fetch<FoundExceptionListItemSchema>(`${EXCEPTION_LIST_ITEM_URL}/_find`, {
method: 'GET',
query: { list_id: listId },
query,
signal,
});
};
/**
* Fetch an ExceptionListItem by providing a ExceptionListItem ID
*
* @param http Kibana http service
* @param id ExceptionListItem ID (not item_id)
* @param namespaceType ExceptionList namespace_type
* @param signal to cancel request
*
* @throws An error if response is not OK
@ -111,18 +155,21 @@ export const fetchExceptionListItemsByListId = async ({
export const fetchExceptionListItemById = async ({
http,
id,
namespaceType,
signal,
}: ApiCallByIdProps): Promise<ExceptionListItemSchema> =>
http.fetch<ExceptionListItemSchema>(`${EXCEPTION_LIST_ITEM_URL}`, {
method: 'GET',
query: { id },
query: { id, namespace_type: namespaceType },
signal,
});
/**
* Delete an ExceptionList by providing a ExceptionList ID
*
* @param http Kibana http service
* @param id ExceptionList ID (not list_id)
* @param namespaceType ExceptionList namespace_type
* @param signal to cancel request
*
* @throws An error if response is not OK
@ -130,18 +177,21 @@ export const fetchExceptionListItemById = async ({
export const deleteExceptionListById = async ({
http,
id,
namespaceType,
signal,
}: ApiCallByIdProps): Promise<ExceptionListSchema> =>
http.fetch<ExceptionListSchema>(`${EXCEPTION_LIST_URL}`, {
method: 'DELETE',
query: { id },
query: { id, namespace_type: namespaceType },
signal,
});
/**
* Delete an ExceptionListItem by providing a ExceptionListItem ID
*
* @param http Kibana http service
* @param id ExceptionListItem ID (not item_id)
* @param namespaceType ExceptionList namespace_type
* @param signal to cancel request
*
* @throws An error if response is not OK
@ -149,10 +199,11 @@ export const deleteExceptionListById = async ({
export const deleteExceptionListItemById = async ({
http,
id,
namespaceType,
signal,
}: ApiCallByIdProps): Promise<ExceptionListItemSchema> =>
http.fetch<ExceptionListItemSchema>(`${EXCEPTION_LIST_ITEM_URL}`, {
method: 'DELETE',
query: { id },
query: { id, namespace_type: namespaceType },
signal,
});

View file

@ -6,8 +6,10 @@
import { act, renderHook } from '@testing-library/react-hooks';
import * as api from '../api';
import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock';
import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core';
import { PersistHookProps } from '../types';
import { ReturnPersistExceptionItem, usePersistExceptionItem } from './persist_exception_item';
@ -16,38 +18,66 @@ jest.mock('../api');
const mockKibanaHttpService = createKibanaCoreStartMock().http;
describe('usePersistExceptionItem', () => {
test('init', async () => {
const onError = jest.fn();
const { result } = renderHook<unknown, ReturnPersistExceptionItem>(() =>
const onError = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('initializes hook', async () => {
const { result } = renderHook<PersistHookProps, ReturnPersistExceptionItem>(() =>
usePersistExceptionItem({ http: mockKibanaHttpService, onError })
);
expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]);
});
test('saving exception item with isLoading === true', async () => {
test('"isLoading" is "true" when exception item is being saved', async () => {
await act(async () => {
const onError = jest.fn();
const { result, rerender, waitForNextUpdate } = renderHook<void, ReturnPersistExceptionItem>(
() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })
);
const { result, rerender, waitForNextUpdate } = renderHook<
PersistHookProps,
ReturnPersistExceptionItem
>(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError }));
await waitForNextUpdate();
result.current[1](getExceptionListItemSchemaMock());
rerender();
expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]);
});
});
test('saved exception item with isSaved === true', async () => {
const onError = jest.fn();
test('"isSaved" is "true" when exception item saved successfully', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPersistExceptionItem>(() =>
usePersistExceptionItem({ http: mockKibanaHttpService, onError })
);
const { result, waitForNextUpdate } = renderHook<
PersistHookProps,
ReturnPersistExceptionItem
>(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError }));
await waitForNextUpdate();
result.current[1](getExceptionListItemSchemaMock());
await waitForNextUpdate();
expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]);
});
});
test('"onError" callback is invoked and "isSaved" is "false" when api call fails', async () => {
const error = new Error('persist rule failed');
jest.spyOn(api, 'addExceptionListItem').mockRejectedValue(error);
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
PersistHookProps,
ReturnPersistExceptionItem
>(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError }));
await waitForNextUpdate();
result.current[1](getExceptionListItemSchemaMock());
await waitForNextUpdate();
expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]);
expect(onError).toHaveBeenCalledWith(error);
});
});
});

View file

@ -19,6 +19,13 @@ export type ReturnPersistExceptionItem = [
Dispatch<AddExceptionListItem | null>
];
/**
* Hook for creating or updating ExceptionListItem
*
* @param http Kibana http service
* @param onError error callback
*
*/
export const usePersistExceptionItem = ({
http,
onError,

View file

@ -6,8 +6,10 @@
import { act, renderHook } from '@testing-library/react-hooks';
import * as api from '../api';
import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock';
import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core';
import { PersistHookProps } from '../types';
import { ReturnPersistExceptionList, usePersistExceptionList } from './persist_exception_list';
@ -16,38 +18,63 @@ jest.mock('../api');
const mockKibanaHttpService = createKibanaCoreStartMock().http;
describe('usePersistExceptionList', () => {
test('init', async () => {
const onError = jest.fn();
const { result } = renderHook<unknown, ReturnPersistExceptionList>(() =>
const onError = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('initializes hook', async () => {
const { result } = renderHook<PersistHookProps, ReturnPersistExceptionList>(() =>
usePersistExceptionList({ http: mockKibanaHttpService, onError })
);
expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]);
});
test('saving exception list with isLoading === true', async () => {
const onError = jest.fn();
test('"isLoading" is "true" when exception item is being saved', async () => {
await act(async () => {
const { result, rerender, waitForNextUpdate } = renderHook<void, ReturnPersistExceptionList>(
() => usePersistExceptionList({ http: mockKibanaHttpService, onError })
);
const { result, rerender, waitForNextUpdate } = renderHook<
PersistHookProps,
ReturnPersistExceptionList
>(() => usePersistExceptionList({ http: mockKibanaHttpService, onError }));
await waitForNextUpdate();
result.current[1](getExceptionListSchemaMock());
rerender();
expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]);
});
});
test('saved exception list with isSaved === true', async () => {
const onError = jest.fn();
test('"isSaved" is "true" when exception item saved successfully', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<void, ReturnPersistExceptionList>(() =>
usePersistExceptionList({ http: mockKibanaHttpService, onError })
);
const { result, waitForNextUpdate } = renderHook<
PersistHookProps,
ReturnPersistExceptionList
>(() => usePersistExceptionList({ http: mockKibanaHttpService, onError }));
await waitForNextUpdate();
result.current[1](getExceptionListSchemaMock());
await waitForNextUpdate();
expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]);
});
});
test('"onError" callback is invoked and "isSaved" is "false" when api call fails', async () => {
const error = new Error('persist rule failed');
jest.spyOn(api, 'addExceptionList').mockRejectedValue(error);
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
PersistHookProps,
ReturnPersistExceptionList
>(() => usePersistExceptionList({ http: mockKibanaHttpService, onError }));
await waitForNextUpdate();
result.current[1](getExceptionListSchemaMock());
await waitForNextUpdate();
expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]);
expect(onError).toHaveBeenCalledWith(error);
});
});
});

View file

@ -19,6 +19,13 @@ export type ReturnPersistExceptionList = [
Dispatch<AddExceptionList | null>
];
/**
* Hook for creating or updating ExceptionList
*
* @param http Kibana http service
* @param onError error callback
*
*/
export const usePersistExceptionList = ({
http,
onError,

View file

@ -8,6 +8,9 @@ import { act, renderHook } from '@testing-library/react-hooks';
import * as api from '../api';
import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core';
import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock';
import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock';
import { ExceptionListAndItems, UseExceptionListProps } from '../types';
import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list';
@ -16,103 +19,166 @@ jest.mock('../api');
const mockKibanaHttpService = createKibanaCoreStartMock().http;
describe('useExceptionList', () => {
test('init', async () => {
const onError = jest.fn();
const onErrorMock = jest.fn();
afterEach(() => {
onErrorMock.mockClear();
jest.clearAllMocks();
});
test('initializes hook', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, ReturnExceptionListAndItems>(() =>
useExceptionList({ http: mockKibanaHttpService, id: 'myListId', onError })
const { result, waitForNextUpdate } = renderHook<
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
http: mockKibanaHttpService,
id: 'myListId',
namespaceType: 'single',
onError: onErrorMock,
})
);
await waitForNextUpdate();
expect(result.current).toEqual([true, null]);
expect(result.current).toEqual([true, null, result.current[2]]);
expect(typeof result.current[2]).toEqual('function');
});
});
test('fetch exception list and items', async () => {
const onError = jest.fn();
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, ReturnExceptionListAndItems>(() =>
useExceptionList({ http: mockKibanaHttpService, id: 'myListId', onError })
const { result, waitForNextUpdate } = renderHook<
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
http: mockKibanaHttpService,
id: 'myListId',
namespaceType: 'single',
onError: onErrorMock,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual([
false,
{
_tags: ['endpoint', 'process', 'malware', 'os:linux'],
created_at: '2020-04-23T00:19:13.289Z',
created_by: 'user_name',
description: 'This is a sample endpoint type exception',
exceptionItems: {
data: [
{
_tags: ['endpoint', 'process', 'malware', 'os:linux'],
comment: [],
created_at: '2020-04-23T00:19:13.289Z',
created_by: 'user_name',
description: 'This is a sample endpoint type exception',
entries: [
{
field: 'actingProcess.file.signer',
match: 'Elastic, N.V.',
match_any: undefined,
operator: 'included',
},
{
field: 'event.category',
match: undefined,
match_any: ['process', 'malware'],
operator: 'included',
},
],
id: '1',
item_id: 'endpoint_list_item',
list_id: 'endpoint_list',
meta: {},
name: 'Sample Endpoint Exception List',
namespace_type: 'single',
tags: ['user added string for a tag', 'malware'],
tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f',
type: 'simple',
updated_at: '2020-04-23T00:19:13.289Z',
updated_by: 'user_name',
},
],
const expectedResult: ExceptionListAndItems = {
...getExceptionListSchemaMock(),
exceptionItems: {
items: [{ ...getExceptionListItemSchemaMock() }],
pagination: {
page: 1,
per_page: 20,
perPage: 20,
total: 1,
},
id: '1',
list_id: 'endpoint_list',
meta: {},
name: 'Sample Endpoint Exception List',
namespace_type: 'single',
tags: ['user added string for a tag', 'malware'],
tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f',
type: 'endpoint',
updated_at: '2020-04-23T00:19:13.289Z',
updated_by: 'user_name',
},
]);
};
expect(result.current).toEqual([false, expectedResult, result.current[2]]);
});
});
test('fetch a new exception list and its items', async () => {
const onError = jest.fn();
const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById');
const spyOnfetchExceptionListItemsByListId = jest.spyOn(api, 'fetchExceptionListItemsByListId');
await act(async () => {
const { rerender, waitForNextUpdate } = renderHook<string, ReturnExceptionListAndItems>(
(id) => useExceptionList({ http: mockKibanaHttpService, id, onError }),
const { rerender, waitForNextUpdate } = renderHook<
UseExceptionListProps,
ReturnExceptionListAndItems
>(
({ filterOptions, http, id, namespaceType, pagination, onError }) =>
useExceptionList({ filterOptions, http, id, namespaceType, onError, pagination }),
{
initialProps: 'myListId',
initialProps: {
http: mockKibanaHttpService,
id: 'myListId',
namespaceType: 'single',
onError: onErrorMock,
},
}
);
await waitForNextUpdate();
rerender({
http: mockKibanaHttpService,
id: 'newListId',
namespaceType: 'single',
onError: onErrorMock,
});
await waitForNextUpdate();
rerender('newListId');
await waitForNextUpdate();
expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(2);
expect(spyOnfetchExceptionListItemsByListId).toHaveBeenCalledTimes(2);
});
});
test('fetches list and items when refreshExceptionList callback invoked', async () => {
const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById');
const spyOnfetchExceptionListItemsByListId = jest.spyOn(api, 'fetchExceptionListItemsByListId');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseExceptionListProps,
ReturnExceptionListAndItems
>(() =>
useExceptionList({
http: mockKibanaHttpService,
id: 'myListId',
namespaceType: 'single',
onError: onErrorMock,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current[2]();
await waitForNextUpdate();
expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(2);
expect(spyOnfetchExceptionListItemsByListId).toHaveBeenCalledTimes(2);
});
});
test('invokes "onError" callback if "fetchExceptionListItemsByListId" fails', async () => {
const mockError = new Error('failed to fetch list items');
const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById');
const spyOnfetchExceptionListItemsByListId = jest
.spyOn(api, 'fetchExceptionListItemsByListId')
.mockRejectedValue(mockError);
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListProps, ReturnExceptionListAndItems>(
() =>
useExceptionList({
http: mockKibanaHttpService,
id: 'myListId',
namespaceType: 'single',
onError: onErrorMock,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnfetchExceptionListById).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(mockError);
expect(spyOnfetchExceptionListItemsByListId).toHaveBeenCalledTimes(1);
});
});
test('invokes "onError" callback if "fetchExceptionListById" fails', async () => {
const mockError = new Error('failed to fetch list');
jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError);
await act(async () => {
const { waitForNextUpdate } = renderHook<UseExceptionListProps, ReturnExceptionListAndItems>(
() =>
useExceptionList({
http: mockKibanaHttpService,
id: 'myListId',
namespaceType: 'single',
onError: onErrorMock,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(onErrorMock).toHaveBeenCalledWith(mockError);
});
});
});

View file

@ -4,66 +4,124 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { fetchExceptionListById, fetchExceptionListItemsByListId } from '../api';
import { ExceptionListAndItems, UseExceptionListProps } from '../types';
export type ReturnExceptionListAndItems = [boolean, ExceptionListAndItems | null];
export type ReturnExceptionListAndItems = [boolean, ExceptionListAndItems | null, () => void];
/**
* Hook for using to get an ExceptionList and it's ExceptionListItems
*
* @param http Kibana http service
* @param id desired ExceptionList ID (not list_id)
* @param namespaceType list namespaceType determines list space
* @param onError error callback
* @param filterOptions optional - filter by fields or tags
* @param pagination optional
*
*/
export const useExceptionList = ({
http,
id,
namespaceType,
pagination = {
page: 1,
perPage: 20,
total: 0,
},
filterOptions = {
filter: '',
tags: [],
},
onError,
}: UseExceptionListProps): ReturnExceptionListAndItems => {
const [exceptionListAndItems, setExceptionList] = useState<ExceptionListAndItems | null>(null);
const [shouldRefresh, setRefresh] = useState<boolean>(true);
const refreshExceptionList = useCallback(() => setRefresh(true), [setRefresh]);
const [loading, setLoading] = useState(true);
const tags = filterOptions.tags.sort().join();
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
useEffect(
() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchData = async (idToFetch: string): Promise<void> => {
try {
setLoading(true);
const exceptionList = await fetchExceptionListById({
http,
id: idToFetch,
signal: abortCtrl.signal,
});
const exceptionListItems = await fetchExceptionListItemsByListId({
http,
listId: exceptionList.list_id,
signal: abortCtrl.signal,
});
if (isSubscribed) {
setExceptionList({ ...exceptionList, exceptionItems: { ...exceptionListItems } });
const fetchData = async (idToFetch: string): Promise<void> => {
if (shouldRefresh) {
try {
setLoading(true);
const {
list_id,
namespace_type,
...restOfExceptionList
} = await fetchExceptionListById({
http,
id: idToFetch,
namespaceType,
signal: abortCtrl.signal,
});
const fetchListItemsResult = await fetchExceptionListItemsByListId({
filterOptions,
http,
listId: list_id,
namespaceType: namespace_type,
pagination,
signal: abortCtrl.signal,
});
setRefresh(false);
if (isSubscribed) {
setExceptionList({
list_id,
namespace_type,
...restOfExceptionList,
exceptionItems: {
items: [...fetchListItemsResult.data],
pagination: {
page: fetchListItemsResult.page,
perPage: fetchListItemsResult.per_page,
total: fetchListItemsResult.total,
},
},
});
}
} catch (error) {
setRefresh(false);
if (isSubscribed) {
setExceptionList(null);
onError(error);
}
}
}
} catch (error) {
if (isSubscribed) {
setExceptionList(null);
onError(error);
setLoading(false);
}
}
if (isSubscribed) {
setLoading(false);
}
};
};
if (id != null) {
fetchData(id);
}
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [http, id, onError]);
if (id != null) {
fetchData(id);
}
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, // eslint-disable-next-line react-hooks/exhaustive-deps
[
http,
id,
onError,
shouldRefresh,
pagination.page,
pagination.perPage,
filterOptions.filter,
tags,
]
);
return [loading, exceptionListAndItems];
return [loading, exceptionListAndItems, refreshExceptionList];
};

View file

@ -9,12 +9,28 @@ import {
CreateExceptionListSchemaPartial,
ExceptionListItemSchema,
ExceptionListSchema,
FoundExceptionListItemSchema,
NamespaceType,
} from '../../common/schemas';
import { HttpStart } from '../../../../../src/core/public';
export interface FilterExceptionsOptions {
filter: string;
tags: string[];
}
export interface Pagination {
page: number;
perPage: number;
total: number;
}
export interface ExceptionItemsAndPagination {
items: ExceptionListItemSchema[];
pagination: Pagination;
}
export interface ExceptionListAndItems extends ExceptionListSchema {
exceptionItems: FoundExceptionListItemSchema;
exceptionItems: ExceptionItemsAndPagination;
}
export type AddExceptionList = ExceptionListSchema | CreateExceptionListSchemaPartial;
@ -27,20 +43,27 @@ export interface PersistHookProps {
}
export interface UseExceptionListProps {
filterOptions?: FilterExceptionsOptions;
http: HttpStart;
id: string | undefined;
namespaceType: NamespaceType;
onError: (arg: Error) => void;
pagination?: Pagination;
}
export interface ApiCallByListIdProps {
http: HttpStart;
listId: string;
namespaceType: NamespaceType;
filterOptions?: FilterExceptionsOptions;
pagination?: Pagination;
signal: AbortSignal;
}
export interface ApiCallByIdProps {
http: HttpStart;
id: string;
namespaceType: NamespaceType;
signal: AbortSignal;
}