diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/action_log_button.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/action_log_button.tsx index ae540650561b..abb181a0058a 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/action_log_button.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/action_log_button.tsx @@ -36,11 +36,7 @@ export const ActionLogButton = memo((p - + )} diff --git a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/components/action_list_date_range_picker.tsx b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/components/action_list_date_range_picker.tsx index 8fca7a7d9f70..402ae961fba4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/components/action_list_date_range_picker.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/components/action_list_date_range_picker.tsx @@ -10,9 +10,9 @@ import dateMath from '@kbn/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import type { EuiSuperDatePickerRecentRange } from '@elastic/eui'; -import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { DurationRange, OnRefreshChangeProps } from '@elastic/eui/src/components/date_picker/types'; -import { useKibana } from '../../../../../common/lib/kibana/kibana_react'; +import { useUiSetting$ } from '../../../../../common/lib/kibana'; import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../common/constants'; export interface DateRangePickerValues { @@ -70,7 +70,7 @@ export const ActionListDateRangePicker = memo( return ( - + unknown; +}; +jest.mock('../../../hooks/endpoint/use_get_endpoint_action_list', () => { + const original = jest.requireActual('../../../hooks/endpoint/use_get_endpoint_action_list'); + return { + ...original, + useGetEndpointActionList: () => mockUseGetEndpointActionList, + }; +}); + +const mockUseUiSetting$ = useUiSetting$ as jest.Mock; +const timepickerRanges = [ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + { + from: 'now/w', + to: 'now/w', + display: 'This week', + }, + { + from: 'now-15m', + to: 'now', + display: 'Last 15 minutes', + }, + { + from: 'now-30m', + to: 'now', + display: 'Last 30 minutes', + }, + { + from: 'now-1h', + to: 'now', + display: 'Last 1 hour', + }, + { + from: 'now-24h', + to: 'now', + display: 'Last 24 hours', + }, + { + from: 'now-7d', + to: 'now', + display: 'Last 7 days', + }, + { + from: 'now-30d', + to: 'now', + display: 'Last 30 days', + }, + { + from: 'now-90d', + to: 'now', + display: 'Last 90 days', + }, + { + from: 'now-1y', + to: 'now', + display: 'Last 1 year', + }, +]; +jest.mock('../../../../common/lib/kibana'); + +describe('Response Actions List', () => { + const testPrefix = 'response-actions-list'; + + let render: ( + props: React.ComponentProps + ) => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + ({ history } = mockedContext); + render = (props: React.ComponentProps) => + (renderResult = mockedContext.render()); + reactTestingLibrary.act(() => { + history.push(`${MANAGEMENT_PATH}/response_actions`); + }); + (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); + mockUseUiSetting$.mockImplementation((key, defaultValue) => { + const useUiSetting$Mock = createUseUiSetting$Mock(); + + return key === DEFAULT_TIMEPICKER_QUICK_RANGES + ? [timepickerRanges, jest.fn()] + : useUiSetting$Mock(key, defaultValue); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('', () => { + const baseMockedActionList = { + isFetched: true, + isFetching: false, + error: null, + refetch: jest.fn(), + }; + beforeEach(async () => { + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 13 }), + }; + }); + + afterEach(() => { + mockUseGetEndpointActionList = { + refetch: jest.fn(), + }; + }); + + describe('Table View', () => { + it('should show date filters', () => { + render({}); + expect(renderResult.getByTestId('actionListSuperDatePicker')).toBeTruthy(); + }); + + it('should show empty state when there is no data', async () => { + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 0 }), + }; + render({}); + expect(renderResult.getByTestId(`${testPrefix}-empty-prompt`)).toBeTruthy(); + }); + + it('should show table when there is data', async () => { + render({}); + expect(renderResult.getByTestId(`${testPrefix}-table-view`)).toBeTruthy(); + expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent( + 'Showing 1-10 of 13 response actions' + ); + }); + + it('should show expected column names on the table', async () => { + render({}); + expect( + Array.from( + renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th') + ) + .slice(0, 6) + .map((col) => col.textContent) + ).toEqual(['Time', 'Command/action', 'User', 'Host', 'Comments', 'Status']); + }); + + it('should paginate table when there is data', async () => { + render({}); + + expect(renderResult.getByTestId(`${testPrefix}-table-view`)).toBeTruthy(); + expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent( + 'Showing 1-10 of 13 response actions' + ); + + const page2 = renderResult.getByTestId('pagination-button-1'); + userEvent.click(page2); + expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent( + 'Showing 11-13 of 13 response actions' + ); + }); + + it('should show 1-1 record label when only 1 record', async () => { + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 1 }), + }; + render({}); + expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent( + 'Showing 1-1 of 1 response action' + ); + }); + + it('should expand each row to show details', async () => { + render({}); + + const expandButtons = renderResult.getAllByTestId(`${testPrefix}-expand-button`); + expandButtons.map((button) => userEvent.click(button)); + const trays = renderResult.getAllByTestId(`${testPrefix}-output-section`); + expect(trays).toBeTruthy(); + expect(trays.length).toEqual(13); + + expandButtons.map((button) => userEvent.click(button)); + const noTrays = renderResult.queryAllByTestId(`${testPrefix}-output-section`); + expect(noTrays).toEqual([]); + }); + }); + + describe('Without agentIds filter', () => { + it('should show a host column', async () => { + render({}); + expect(renderResult.getByTestId(`tableHeaderCell_agents_3`)).toBeTruthy(); + }); + }); + + describe('With agentIds filter', () => { + it('should NOT show a host column when a single agentId', async () => { + const agentIds = uuid.v4(); + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 2, agentIds: [agentIds] }), + }; + render({ agentIds }); + expect( + Array.from( + renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th') + ) + .slice(0, 5) + .map((col) => col.textContent) + ).toEqual(['Time', 'Command/action', 'User', 'Comments', 'Status']); + }); + + it('should show a host column when multiple agentIds', async () => { + const agentIds = [uuid.v4(), uuid.v4()]; + mockUseGetEndpointActionList = { + ...baseMockedActionList, + data: await getActionListMock({ actionCount: 2, agentIds }), + }; + render({ agentIds }); + expect( + Array.from( + renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th') + ) + .slice(0, 6) + .map((col) => col.textContent) + ).toEqual(['Time', 'Command/action', 'User', 'Host', 'Comments', 'Status']); + }); + }); + }); +}); + +// mock API response +const getActionListMock = async ({ + agentIds: _agentIds, + commands, + actionCount = 0, + endDate, + page = 1, + pageSize = 10, + startDate, + userIds, +}: { + agentIds?: string[]; + commands?: string[]; + actionCount?: number; + endDate?: string; + page?: number; + pageSize?: number; + startDate?: string; + userIds?: string[]; +}): Promise => { + const endpointActionGenerator = new EndpointActionGenerator('seed'); + + const agentIds = _agentIds ?? [uuid.v4()]; + + const data: ActionDetails[] = agentIds.map((id) => { + const actionIds = Array(actionCount) + .fill(1) + .map(() => uuid.v4()); + + const actionDetails: ActionDetails[] = actionIds.map((actionId) => { + return endpointActionGenerator.generateActionDetails({ + agents: [id], + id: actionId, + }); + }); + return actionDetails; + })[0]; + + return { + page, + pageSize, + startDate, + endDate, + elasticAgentIds: agentIds, + commands, + data, + userIds, + total: data.length ?? 0, + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list.tsx index f5dea5a226cf..8214dcad827f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list.tsx @@ -119,7 +119,7 @@ export const ResponseActionsList = memo< hideHeader?: boolean; hideHostNameColumn?: boolean; } ->(({ agentIds, commands, userIds, hideHeader = false, hideHostNameColumn = false }) => { +>(({ agentIds, commands, userIds, hideHeader = false }) => { const getTestId = useTestIdGenerator('response-actions-list'); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ [k: ActionDetails['id']]: React.ReactNode; @@ -284,6 +284,7 @@ export const ResponseActionsList = memo< itemIdToExpandedRowMapValues[item.id] = ( <> {[descriptionListLeft, descriptionListCenter, descriptionListRight].map( - (_list) => { + (_list, i) => { const list = _list.map((l) => { const isParameters = l.title === OUTPUT_MESSAGES.expandSection.parameters; return { @@ -306,7 +307,7 @@ export const ResponseActionsList = memo< }); return ( - + ); @@ -323,7 +324,7 @@ export const ResponseActionsList = memo< } setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); }, - [itemIdToExpandedRowMap] + [getTestId, itemIdToExpandedRowMap] ); // memoized callback for toggleDetails const onClickCallback = useCallback( @@ -487,6 +488,7 @@ export const ResponseActionsList = memo< render: (data: ActionDetails) => { return ( column.field !== 'agents'); } return columns; - }, [getTestId, hideHostNameColumn, itemIdToExpandedRowMap, onClickCallback]); + }, [agentIds, getTestId, itemIdToExpandedRowMap, onClickCallback]); // table pagination const tablePagination = useMemo(() => {