[Security Solution] User can filter Trusted Applications by Hash, Path, 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
This commit is contained in:
David Sánchez 2021-03-30 16:09:34 +02:00 committed by GitHub
parent 36e567b858
commit 1f033f3786
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 230 additions and 5 deletions

View file

@ -127,6 +127,7 @@ describe('routing', () => {
page_size: 20,
show: 'create',
view_type: 'list',
filter: '',
};
expect(getTrustedAppsListPath(location)).toEqual(

View file

@ -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 {};
@ -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 = (

View file

@ -22,6 +22,7 @@ export interface TrustedAppsListData {
pageSize: number;
timestamp: number;
totalItemsCount: number;
filter: string;
}
export type ViewType = 'list' | 'grid';
@ -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 {

View file

@ -56,6 +56,7 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({
show: undefined,
id: undefined,
view_type: 'grid',
filter: '',
},
active: false,
});

View file

@ -46,6 +46,7 @@ import {
getLastLoadedListResourceState,
getCurrentLocationPageIndex,
getCurrentLocationPageSize,
getCurrentLocationFilter,
needsRefreshOfListData,
getCreationSubmissionResourceState,
getCreationDialogFormEntry,
@ -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 = (
@ -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(
@ -104,6 +109,7 @@ const refreshListIfNeeded = async (
pageSize,
totalItemsCount: response.total,
timestamp: Date.now(),
filter,
},
})
);

View file

@ -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'
)
);
@ -43,6 +43,7 @@ describe('reducer', () => {
show: 'create',
view_type: 'list',
id: undefined,
filter: 'test',
},
active: true,
});
@ -50,7 +51,10 @@ describe('reducer', () => {
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')
);
@ -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')
);

View file

@ -93,6 +93,7 @@ describe('selectors', () => {
page_index: 0,
page_size: 10,
view_type: 'grid',
filter: '',
};
expect(needsRefreshOfListData({ ...initialState, listView, active: true, location })).toBe(
@ -174,6 +175,7 @@ describe('selectors', () => {
page_index: 3,
page_size: 10,
view_type: 'grid',
filter: '',
};
expect(getCurrentLocationPageIndex({ ...initialState, location })).toBe(3);
@ -186,6 +188,7 @@ describe('selectors', () => {
page_index: 0,
page_size: 20,
view_type: 'grid',
filter: '',
};
expect(getCurrentLocationPageSize({ ...initialState, location })).toBe(20);

View file

@ -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
);
})
);
@ -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;
};

View file

@ -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*'
);
});
});
});

View file

@ -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;
};

View file

@ -79,6 +79,7 @@ export const createTrustedAppsListData = (
pageIndex: fullPagination.pageIndex,
totalItemsCount: fullPagination.totalItemCount,
timestamp,
filter: '',
};
};

View file

@ -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);
});
});

View file

@ -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';

View file

@ -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');
});
});
});
});

View file

@ -39,6 +39,7 @@ import { TrustedAppsListPageRouteState } from '../../../../../common/endpoint/ty
import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler';
import { ABOUT_TRUSTED_APPS } from './translations';
import { EmptyState } from './components/empty_state';
import { SearchBar } from './components/search_bar';
export const TrustedAppsPage = memo(() => {
const { state: routeState } = useLocation<TrustedAppsListPageRouteState | undefined>();
@ -57,6 +58,7 @@ export const TrustedAppsPage = memo(() => {
const handleViewTypeChange = useTrustedAppsNavigateCallback((viewType: ViewType) => ({
view_type: viewType,
}));
const handleOnSearch = useTrustedAppsNavigateCallback((query: string) => ({ filter: query }));
const showCreateFlyout = !!location.show;
@ -94,12 +96,14 @@ export const TrustedAppsPage = memo(() => {
/>
)}
<SearchBar defaultValue={location.filter} onSearch={handleOnSearch} />
{doEntriesExist ? (
<EuiFlexGroup
direction="column"
gutterSize="none"
data-test-subj="trustedAppsListPageContent"
>
<EuiSpacer size="m" />
<EuiFlexItem grow={false}>
<ControlPanel
totalItemCount={totalItemsCount}