From b54d730a97cdd1a6ba18de6b9fbe5d629a1951af Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Wed, 25 Aug 2021 16:43:20 +0200 Subject: [PATCH] [Security Solution][Endpoint] Additional Endpoint Activity log tests (#109776) * move activity log paging method close to call api method refs 417d093a29feff76fa1896a8584eb8af0f9d4a10 * add middleware additional activity log tests * add a more specific server side test for activity log actions and responses refs elastic/kibana/pull/101032 * remove obsolete server side audit log index mock method refs elastic/kibana/pull/101032 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../endpoint_hosts/store/middleware.test.ts | 87 ++++++++ .../pages/endpoint_hosts/store/middleware.ts | 188 +++++++++--------- .../endpoint/routes/actions/audit_log.test.ts | 62 +++--- .../server/endpoint/routes/actions/mocks.ts | 23 --- 4 files changed, 215 insertions(+), 145 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 57bec2bbd7c06..96314ca154d1f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -19,6 +19,7 @@ import { HostResultList, HostIsolationResponse, ISOLATION_ACTIONS, + ActivityLog, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -244,6 +245,29 @@ describe('endpoint list middleware', () => { }); }; + const dispatchGetActivityLogPaging = ({ page = 1 }: { page: number }) => { + dispatch({ + type: 'endpointDetailsActivityLogUpdatePaging', + payload: { + page, + pageSize: 50, + }, + }); + }; + + const dispatchGetActivityLogUpdateInvalidDateRange = ({ + isInvalidDateRange = false, + }: { + isInvalidDateRange: boolean; + }) => { + dispatch({ + type: 'endpointDetailsActivityLogUpdateIsInvalidDateRange', + payload: { + isInvalidDateRange, + }, + }); + }; + it('should set ActivityLog state to loading', async () => { dispatchUserChangedUrl(); dispatchGetActivityLogLoading(); @@ -284,6 +308,69 @@ describe('endpoint list middleware', () => { }); expect(activityLogResponse.payload.type).toEqual('LoadedResourceState'); }); + + it('should set ActivityLog to Failed if API call fails', async () => { + dispatchUserChangedUrl(); + + const apiError = new Error('oh oh'); + const failedDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isFailedResourceState(action.payload); + }, + }); + + mockedApis.responseProvider.activityLogResponse.mockImplementation(() => { + throw apiError; + }); + + const failedAction = (await failedDispatched).payload as FailedResourceState; + expect(failedAction.error).toBe(apiError); + }); + + it('should not fetch Activity Log with invalid date ranges', async () => { + dispatchUserChangedUrl(); + + const updateInvalidDateRangeDispatched = waitForAction( + 'endpointDetailsActivityLogUpdateIsInvalidDateRange' + ); + dispatchGetActivityLogUpdateInvalidDateRange({ isInvalidDateRange: true }); + await updateInvalidDateRangeDispatched; + + expect(mockedApis.responseProvider.activityLogResponse).not.toHaveBeenCalled(); + }); + + it('should call get Activity Log API with valid date ranges', async () => { + dispatchUserChangedUrl(); + + const updatePagingDispatched = waitForAction('endpointDetailsActivityLogUpdatePaging'); + dispatchGetActivityLogPaging({ page: 1 }); + + const updateInvalidDateRangeDispatched = waitForAction( + 'endpointDetailsActivityLogUpdateIsInvalidDateRange' + ); + dispatchGetActivityLogUpdateInvalidDateRange({ isInvalidDateRange: false }); + await updateInvalidDateRangeDispatched; + await updatePagingDispatched; + + expect(mockedApis.responseProvider.activityLogResponse).toHaveBeenCalled(); + }); + + it('should call get Activity Log API with correct paging options', async () => { + dispatchUserChangedUrl(); + + const updatePagingDispatched = waitForAction('endpointDetailsActivityLogUpdatePaging'); + dispatchGetActivityLogPaging({ page: 3 }); + + await updatePagingDispatched; + + expect(mockedApis.responseProvider.activityLogResponse).toHaveBeenCalledWith({ + path: expect.any(String), + query: { + page: 3, + page_size: 50, + }, + }); + }); }); describe('handle Endpoint Pending Actions state actions', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index ca5af088b36f6..1be9ff5be55ef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -120,6 +120,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory; - coreStart: CoreStart; -}) { - const { getState, dispatch } = store; - try { - const { disabled, page, pageSize, startDate, endDate } = getActivityLogDataPaging(getState()); - // don't page when paging is disabled or when date ranges are invalid - if (disabled) { - return; - } - if (getIsInvalidDateRange({ startDate, endDate })) { - dispatch({ - type: 'endpointDetailsActivityLogUpdateIsInvalidDateRange', - payload: { - isInvalidDateRange: true, - }, - }); - return; - } - - dispatch({ - type: 'endpointDetailsActivityLogUpdateIsInvalidDateRange', - payload: { - isInvalidDateRange: false, - }, - }); - dispatch({ - type: 'endpointDetailsActivityLogChanged', - // ts error to be fixed when AsyncResourceState is refactored (#830) - // @ts-expect-error - payload: createLoadingResourceState(getActivityLogData(getState())), - }); - const route = resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { - agent_id: selectedAgent(getState()), - }); - const activityLog = await coreStart.http.get(route, { - query: { - page, - page_size: pageSize, - start_date: startDate, - end_date: endDate, - }, - }); - - const lastLoadedLogData = getLastLoadedActivityLogData(getState()); - if (lastLoadedLogData !== undefined) { - const updatedLogDataItems = ([ - ...new Set([...lastLoadedLogData.data, ...activityLog.data]), - ] as ActivityLog['data']).sort((a, b) => - new Date(b.item.data['@timestamp']) > new Date(a.item.data['@timestamp']) ? 1 : -1 - ); - - const updatedLogData = { - page: activityLog.page, - pageSize: activityLog.pageSize, - startDate: activityLog.startDate, - endDate: activityLog.endDate, - data: activityLog.page === 1 ? activityLog.data : updatedLogDataItems, - }; - dispatch({ - type: 'endpointDetailsActivityLogChanged', - payload: createLoadedResourceState(updatedLogData), - }); - if (!activityLog.data.length) { - dispatch({ - type: 'endpointDetailsActivityLogUpdatePaging', - payload: { - disabled: true, - page: activityLog.page > 1 ? activityLog.page - 1 : 1, - pageSize: activityLog.pageSize, - startDate: activityLog.startDate, - endDate: activityLog.endDate, - }, - }); - } - } else { - dispatch({ - type: 'endpointDetailsActivityLogChanged', - payload: createLoadedResourceState(activityLog), - }); - } - } catch (error) { - dispatch({ - type: 'endpointDetailsActivityLogChanged', - payload: createFailedResourceState(error.body ?? error), - }); - } -} - async function loadEndpointDetails({ selectedEndpoint, store, @@ -720,7 +628,6 @@ async function endpointDetailsActivityLogChangedMiddleware({ coreStart: CoreStart; }) { const { getState, dispatch } = store; - // call the activity log api dispatch({ type: 'endpointDetailsActivityLogChanged', // ts error to be fixed when AsyncResourceState is refactored (#830) @@ -748,6 +655,99 @@ async function endpointDetailsActivityLogChangedMiddleware({ } } +async function endpointDetailsActivityLogPagingMiddleware({ + store, + coreStart, +}: { + store: ImmutableMiddlewareAPI; + coreStart: CoreStart; +}) { + const { getState, dispatch } = store; + try { + const { disabled, page, pageSize, startDate, endDate } = getActivityLogDataPaging(getState()); + // don't page when paging is disabled or when date ranges are invalid + if (disabled) { + return; + } + if (getIsInvalidDateRange({ startDate, endDate })) { + dispatch({ + type: 'endpointDetailsActivityLogUpdateIsInvalidDateRange', + payload: { + isInvalidDateRange: true, + }, + }); + return; + } + + dispatch({ + type: 'endpointDetailsActivityLogUpdateIsInvalidDateRange', + payload: { + isInvalidDateRange: false, + }, + }); + dispatch({ + type: 'endpointDetailsActivityLogChanged', + // ts error to be fixed when AsyncResourceState is refactored (#830) + // @ts-expect-error + payload: createLoadingResourceState(getActivityLogData(getState())), + }); + const route = resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { + agent_id: selectedAgent(getState()), + }); + const activityLog = await coreStart.http.get(route, { + query: { + page, + page_size: pageSize, + start_date: startDate, + end_date: endDate, + }, + }); + + const lastLoadedLogData = getLastLoadedActivityLogData(getState()); + if (lastLoadedLogData !== undefined) { + const updatedLogDataItems = ([ + ...new Set([...lastLoadedLogData.data, ...activityLog.data]), + ] as ActivityLog['data']).sort((a, b) => + new Date(b.item.data['@timestamp']) > new Date(a.item.data['@timestamp']) ? 1 : -1 + ); + + const updatedLogData = { + page: activityLog.page, + pageSize: activityLog.pageSize, + startDate: activityLog.startDate, + endDate: activityLog.endDate, + data: activityLog.page === 1 ? activityLog.data : updatedLogDataItems, + }; + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(updatedLogData), + }); + if (!activityLog.data.length) { + dispatch({ + type: 'endpointDetailsActivityLogUpdatePaging', + payload: { + disabled: true, + page: activityLog.page > 1 ? activityLog.page - 1 : 1, + pageSize: activityLog.pageSize, + startDate: activityLog.startDate, + endDate: activityLog.endDate, + }, + }); + } + } else { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + } + } catch (error) { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } +} + export async function handleLoadMetadataTransformStats(http: HttpStart, store: EndpointPageStore) { const { getState, dispatch } = store; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts index d9069444a10d7..83f38bc904576 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts @@ -30,7 +30,7 @@ import { } from '../../mocks'; import { registerActionAuditLogRoutes } from './audit_log'; import uuid from 'uuid'; -import { aMockAction, aMockResponse, MockAction, mockAuditLog, MockResponse } from './mocks'; +import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; import { SecuritySolutionRequestHandlerContext } from '../../../types'; import { ActivityLog } from '../../../../common/endpoint/types'; @@ -105,10 +105,11 @@ describe('Action Log API', () => { // convenience for calling the route and handler for audit log let getActivityLog: ( + params: EndpointActionLogRequestParams, query?: EndpointActionLogRequestQuery ) => Promise>; // convenience for injecting mock responses for actions index and responses - let havingActionsAndResponses: (actions: MockAction[], responses: any[]) => void; + let havingActionsAndResponses: (actions: MockAction[], responses: MockResponse[]) => void; let havingErrors: () => void; @@ -125,9 +126,12 @@ describe('Action Log API', () => { experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), }); - getActivityLog = async (query?: any): Promise> => { + getActivityLog = async ( + params: { agent_id: string }, + query?: { page: number; page_size: number; start_date?: string; end_date?: string } + ): Promise> => { const req = httpServerMock.createKibanaRequest({ - params: { agent_id: mockID }, + params, query, }); const mockResponse = httpServerMock.createResponseFactory(); @@ -152,18 +156,12 @@ describe('Action Log API', () => { }; havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { - const actionsData = actions.map((a) => ({ - _index: '.fleet-actions-7', - _source: a.build(), - })); - const responsesData = responses.map((r) => ({ - _index: '.ds-.fleet-actions-results-2021.06.09-000001', - _source: r.build(), - })); - const mockResult = mockAuditLog([...actionsData, ...responsesData]); - esClientMock.asCurrentUser.search = jest - .fn() - .mockImplementationOnce(() => Promise.resolve(mockResult)); + esClientMock.asCurrentUser.search = jest.fn().mockImplementation((req) => { + const items: any[] = + req.index === '.fleet-actions' ? actions.splice(0, 50) : responses.splice(0, 1000); + + return Promise.resolve(mockSearchResult(items.map((x) => x.build()))); + }); }; havingErrors = () => { @@ -181,28 +179,33 @@ describe('Action Log API', () => { it('should return an empty array when nothing in audit log', async () => { havingActionsAndResponses([], []); - const response = await getActivityLog(); + const response = await getActivityLog({ agent_id: mockID }); expect(response.ok).toBeCalled(); expect((response.ok.mock.calls[0][0]?.body as ActivityLog).data).toHaveLength(0); }); it('should have actions and action responses', async () => { havingActionsAndResponses( - [aMockAction().withAgent(mockID).withAction('isolate').withID(actionID)], - [aMockResponse(actionID, mockID)] + [ + aMockAction().withAgent(mockID).withAction('isolate').withID(actionID), + aMockAction().withAgent(mockID).withAction('unisolate'), + ], + [aMockResponse(actionID, mockID).forAction(actionID).forAgent(mockID)] ); - const response = await getActivityLog(); + const response = await getActivityLog({ agent_id: mockID }); const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; expect(response.ok).toBeCalled(); - expect(responseBody.data).toHaveLength(2); + expect(responseBody.data).toHaveLength(3); + expect(responseBody.data.filter((e) => e.type === 'response')).toHaveLength(1); + expect(responseBody.data.filter((e) => e.type === 'action')).toHaveLength(2); }); it('should throw errors when no results for some agentID', async () => { havingErrors(); try { - await getActivityLog(); + await getActivityLog({ agent_id: mockID }); } catch (error) { expect(error.message).toEqual(`Error fetching actions log for agent_id ${mockID}`); } @@ -212,12 +215,15 @@ describe('Action Log API', () => { havingActionsAndResponses([], []); const startDate = new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(); const endDate = new Date().toISOString(); - const response = await getActivityLog({ - page: 1, - page_size: 50, - start_date: startDate, - end_date: endDate, - }); + const response = await getActivityLog( + { agent_id: mockID }, + { + page: 1, + page_size: 50, + start_date: startDate, + end_date: endDate, + } + ); expect(response.ok).toBeCalled(); expect((response.ok.mock.calls[0][0]?.body as ActivityLog).startDate).toEqual(startDate); expect((response.ok.mock.calls[0][0]?.body as ActivityLog).endDate).toEqual(endDate); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts index f74ae07fdfac4..34f7d140a78de 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -18,29 +18,6 @@ import { ISOLATION_ACTIONS, } from '../../../../common/endpoint/types'; -export const mockAuditLog = (results: any = []): ApiResponse => { - return { - body: { - hits: { - total: results.length, - hits: results.map((a: any) => { - const _index = a._index; - delete a._index; - const _source = a; - return { - _index, - _source, - }; - }), - }, - }, - statusCode: 200, - headers: {}, - warnings: [], - meta: {} as any, - }; -}; - export const mockSearchResult = (results: any = []): ApiResponse => { return { body: {