Skip to content

Commit

Permalink
[Security Solution] User can filter Trusted Applications by Hash, Pat…
Browse files Browse the repository at this point in the history
…h, Signer, or Trusted App name (#95532)

* Allows filter param. Empty by default

* Uses KQL for filter from Ui

* Adds search bar to dispatch trusted apps search. Fixes some type errors. Added filter into the list View state

* Fix tests and added a new one. Also split query on array to improve readability

* Decouple query parser to be used outside the middleware

* Reuse code using a map

* Filter by term using wildcards. Updates test

* Adds useCallback to memoize function
  • Loading branch information
dasansol92 authored Mar 30, 2021
1 parent 36e567b commit 1f033f3
Show file tree
Hide file tree
Showing 15 changed files with 230 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ describe('routing', () => {
page_size: 20,
show: 'create',
view_type: 'list',
filter: '',
};

expect(getTrustedAppsListPath(location)).toEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const normalizeTrustedAppsPageLocation = (
...(!isDefaultOrMissing(location.view_type, 'grid') ? { view_type: location.view_type } : {}),
...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}),
...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}),
...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''),
};
} else {
return {};
Expand Down Expand Up @@ -141,9 +142,14 @@ const extractPageSize = (query: querystring.ParsedUrlQuery): number => {
return MANAGEMENT_PAGE_SIZE_OPTIONS.includes(pageSize) ? pageSize : MANAGEMENT_DEFAULT_PAGE_SIZE;
};

const extractFilter = (query: querystring.ParsedUrlQuery): string => {
return extractFirstParamValue(query, 'filter') || '';
};

export const extractListPaginationParams = (query: querystring.ParsedUrlQuery) => ({
page_index: extractPageIndex(query),
page_size: extractPageSize(query),
filter: extractFilter(query),
});

export const extractTrustedAppsListPageLocation = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface TrustedAppsListData {
pageSize: number;
timestamp: number;
totalItemsCount: number;
filter: string;
}

export type ViewType = 'list' | 'grid';
Expand All @@ -33,6 +34,7 @@ export interface TrustedAppsListPageLocation {
show?: 'create' | 'edit';
/** Used for editing. The ID of the selected trusted app */
id?: string;
filter: string;
}

export interface TrustedAppsListPageState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({
show: undefined,
id: undefined,
view_type: 'grid',
filter: '',
},
active: false,
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
getLastLoadedListResourceState,
getCurrentLocationPageIndex,
getCurrentLocationPageSize,
getCurrentLocationFilter,
needsRefreshOfListData,
getCreationSubmissionResourceState,
getCreationDialogFormEntry,
Expand All @@ -63,6 +64,7 @@ import {
getListItems,
editItemState,
} from './selectors';
import { parseQueryFilterToKQL } from './utils';
import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app';

const createTrustedAppsListResourceStateChangedAction = (
Expand Down Expand Up @@ -90,9 +92,12 @@ const refreshListIfNeeded = async (
try {
const pageIndex = getCurrentLocationPageIndex(store.getState());
const pageSize = getCurrentLocationPageSize(store.getState());
const filter = getCurrentLocationFilter(store.getState());

const response = await trustedAppsService.getTrustedAppsList({
page: pageIndex + 1,
per_page: pageSize,
kuery: parseQueryFilterToKQL(filter) || undefined,
});

store.dispatch(
Expand All @@ -104,6 +109,7 @@ const refreshListIfNeeded = async (
pageSize,
totalItemsCount: response.total,
timestamp: Date.now(),
filter,
},
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('reducer', () => {
initialState,
createUserChangedUrlAction(
'/trusted_apps',
'?page_index=5&page_size=50&show=create&view_type=list'
'?page_index=5&page_size=50&show=create&view_type=list&filter=test'
)
);

Expand All @@ -43,14 +43,18 @@ describe('reducer', () => {
show: 'create',
view_type: 'list',
id: undefined,
filter: 'test',
},
active: true,
});
});

it('extracts default pagination parameters when invalid provided', () => {
const result = trustedAppsPageReducer(
{ ...initialState, location: { page_index: 5, page_size: 50, view_type: 'grid' } },
{
...initialState,
location: { page_index: 5, page_size: 50, view_type: 'grid', filter: '' },
},
createUserChangedUrlAction('/trusted_apps', '?page_index=b&page_size=60&show=a&view_type=c')
);

Expand All @@ -59,7 +63,10 @@ describe('reducer', () => {

it('extracts default pagination parameters when none provided', () => {
const result = trustedAppsPageReducer(
{ ...initialState, location: { page_index: 5, page_size: 50, view_type: 'grid' } },
{
...initialState,
location: { page_index: 5, page_size: 50, view_type: 'grid', filter: '' },
},
createUserChangedUrlAction('/trusted_apps')
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('selectors', () => {
page_index: 0,
page_size: 10,
view_type: 'grid',
filter: '',
};

expect(needsRefreshOfListData({ ...initialState, listView, active: true, location })).toBe(
Expand Down Expand Up @@ -174,6 +175,7 @@ describe('selectors', () => {
page_index: 3,
page_size: 10,
view_type: 'grid',
filter: '',
};

expect(getCurrentLocationPageIndex({ ...initialState, location })).toBe(3);
Expand All @@ -186,6 +188,7 @@ describe('selectors', () => {
page_index: 0,
page_size: 20,
view_type: 'grid',
filter: '',
};

expect(getCurrentLocationPageSize({ ...initialState, location })).toBe(20);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ export const needsRefreshOfListData = (state: Immutable<TrustedAppsListPageState
const freshDataTimestamp = state.listView.freshDataTimestamp;
const currentPage = state.listView.listResourceState;
const location = state.location;

return (
Boolean(state.active) &&
isOutdatedResourceState(currentPage, (data) => {
return (
data.pageIndex === location.page_index &&
data.pageSize === location.page_size &&
data.timestamp >= freshDataTimestamp
data.timestamp >= freshDataTimestamp &&
data.filter === location.filter
);
})
);
Expand Down Expand Up @@ -69,6 +69,10 @@ export const getCurrentLocationPageSize = (state: Immutable<TrustedAppsListPageS
return state.location.page_size;
};

export const getCurrentLocationFilter = (state: Immutable<TrustedAppsListPageState>): string => {
return state.location.filter;
};

export const getListTotalItemsCount = (state: Immutable<TrustedAppsListPageState>): number => {
return getLastLoadedResourceState(state.listView.listResourceState)?.data.totalItemsCount || 0;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { parseQueryFilterToKQL } from './utils';

describe('utils', () => {
describe('parseQueryFilterToKQL', () => {
it('should parse simple query without term', () => {
expect(parseQueryFilterToKQL('')).toBe('');
});
it('should parse simple query with term', () => {
expect(parseQueryFilterToKQL('simpleQuery')).toBe(
'exception-list-agnostic.attributes.name:*simpleQuery* OR exception-list-agnostic.attributes.description:*simpleQuery* OR exception-list-agnostic.attributes.entries.value:*simpleQuery* OR exception-list-agnostic.attributes.entries.entries.value:*simpleQuery*'
);
});
it('should parse complex query with term', () => {
expect(parseQueryFilterToKQL('complex query')).toBe(
'exception-list-agnostic.attributes.name:*complex* *query* OR exception-list-agnostic.attributes.description:*complex* *query* OR exception-list-agnostic.attributes.entries.value:*complex* *query* OR exception-list-agnostic.attributes.entries.entries.value:*complex* *query*'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export const parseQueryFilterToKQL = (filter: string): string => {
if (!filter) return '';
const kuery = [`name`, `description`, `entries.value`, `entries.entries.value`]
.map(
(field) =>
`exception-list-agnostic.attributes.${field}:*${filter.trim().replace(/\s/gm, '* *')}*`
)
.join(' OR ');

return kuery;
};
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const createTrustedAppsListData = (
pageIndex: fullPagination.pageIndex,
totalItemsCount: fullPagination.totalItemCount,
timestamp,
filter: '',
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { mount } from 'enzyme';
import React from 'react';

import { SearchBar } from '.';

let onSearchMock: jest.Mock;

interface EuiFieldSearchPropsFake {
onSearch(value: string): void;
}

describe('Search bar', () => {
beforeEach(() => {
onSearchMock = jest.fn();
});

const getElement = (defaultValue: string = '') => (
<SearchBar defaultValue={defaultValue} onSearch={onSearchMock} />
);

it('should have a default value', () => {
const expectedDefaultValue = 'this is a default value';
const element = mount(getElement(expectedDefaultValue));
const defaultValue = element.find('[data-test-subj="trustedAppSearchField"]').first().props()
.defaultValue;
expect(defaultValue).toBe(expectedDefaultValue);
});

it('should dispatch search action when submit search field', () => {
const expectedDefaultValue = 'this is a default value';
const element = mount(getElement());
expect(onSearchMock).toHaveBeenCalledTimes(0);
const searchFieldProps = element
.find('[data-test-subj="trustedAppSearchField"]')
.first()
.props() as EuiFieldSearchPropsFake;

searchFieldProps.onSearch(expectedDefaultValue);

expect(onSearchMock).toHaveBeenCalledTimes(1);
expect(onSearchMock).toHaveBeenCalledWith(expectedDefaultValue);
});

it('should dispatch search action when click on button', () => {
const expectedDefaultValue = 'this is a default value';
const element = mount(getElement(expectedDefaultValue));
expect(onSearchMock).toHaveBeenCalledTimes(0);

element.find('[data-test-subj="trustedAppSearchButton"]').first().simulate('click');
expect(onSearchMock).toHaveBeenCalledTimes(1);
expect(onSearchMock).toHaveBeenCalledWith(expectedDefaultValue);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { memo, useCallback, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';

export interface SearchBarProps {
defaultValue?: string;
onSearch(value: string): void;
}

export const SearchBar = memo<SearchBarProps>(({ defaultValue = '', onSearch }) => {
const [query, setQuery] = useState<string>(defaultValue);

const handleOnChangeSearchField = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => setQuery(ev.target.value),
[setQuery]
);
const handleOnSearch = useCallback(() => onSearch(query), [query, onSearch]);

return (
<EuiFlexGroup direction="row" alignItems="center" gutterSize="l">
<EuiFlexItem>
<EuiFieldSearch
defaultValue={query}
placeholder={i18n.translate(
'xpack.securitySolution.trustedapps.list.search.placeholder',
{
defaultMessage: 'Search',
}
)}
onChange={handleOnChangeSearchField}
onSearch={onSearch}
isClearable
fullWidth
data-test-subj="trustedAppSearchField"
/>
</EuiFlexItem>
<EuiFlexItem grow={false} onClick={handleOnSearch} data-test-subj="trustedAppSearchButton">
<EuiButton iconType="refresh">
{i18n.translate('xpack.securitySolution.trustedapps.list.search.button', {
defaultMessage: 'Refresh',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
});

SearchBar.displayName = 'SearchBar';
Original file line number Diff line number Diff line change
Expand Up @@ -860,4 +860,35 @@ describe('When on the Trusted Apps Page', () => {
expect(await renderResult.findByTestId('trustedAppEmptyState')).not.toBeNull();
});
});

describe('and the search is dispatched', () => {
const renderWithListData = async () => {
const result = render();
await act(async () => {
await waitForAction('trustedAppsListResourceStateChanged');
});
return result;
};

beforeEach(() => mockListApis(coreStart.http));

it('search bar is filled with query params', async () => {
reactTestingLibrary.act(() => {
history.push('/trusted_apps?filter=test');
});
const result = await renderWithListData();
expect(result.getByDisplayValue('test')).not.toBeNull();
});

it('search action is dispatched', async () => {
reactTestingLibrary.act(() => {
history.push('/trusted_apps?filter=test');
});
const result = await renderWithListData();
await act(async () => {
fireEvent.click(result.getByTestId('trustedAppSearchButton'));
await waitForAction('userChangedUrl');
});
});
});
});
Loading

0 comments on commit 1f033f3

Please sign in to comment.