[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:
parent
36e567b858
commit
1f033f3786
|
@ -127,6 +127,7 @@ describe('routing', () => {
|
|||
page_size: 20,
|
||||
show: 'create',
|
||||
view_type: 'list',
|
||||
filter: '',
|
||||
};
|
||||
|
||||
expect(getTrustedAppsListPath(location)).toEqual(
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -56,6 +56,7 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({
|
|||
show: undefined,
|
||||
id: undefined,
|
||||
view_type: 'grid',
|
||||
filter: '',
|
||||
},
|
||||
active: false,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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*'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -79,6 +79,7 @@ export const createTrustedAppsListData = (
|
|||
pageIndex: fullPagination.pageIndex,
|
||||
totalItemsCount: fullPagination.totalItemCount,
|
||||
timestamp,
|
||||
filter: '',
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in a new issue