diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index fdfe314094be6..c83fa2bf22211 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -1085,7 +1085,9 @@ export const sendAlertToTimelineAction = async ({ }); } } - } catch { + } catch (error) { + /* eslint-disable-next-line no-console */ + console.error(error); updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); return createTimeline({ from, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx index 417774d7b2bc3..6d20ef3973337 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx @@ -5,21 +5,30 @@ * 2.0. */ import { renderHook, act } from '@testing-library/react-hooks'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { of } from 'rxjs'; import { TestProviders } from '../../../../common/mock'; -import { KibanaServices, useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../common/lib/kibana'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { useInvestigateInTimeline } from './use_investigate_in_timeline'; import * as actions from '../actions'; -import { coreMock } from '@kbn/core/public/mocks'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import type { AlertTableContextMenuItem } from '../types'; import React from 'react'; import { EuiPopover, EuiContextMenu } from '@elastic/eui'; +import * as timelineActions from '../../../../timelines/store/actions'; +import { getTimelineTemplate } from '../../../../timelines/containers/api'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../timelines/containers/api'); +jest.mock('../../../../common/lib/apm/use_start_transaction'); +jest.mock('../../../../common/hooks/use_app_toasts'); const ecsRowData: Ecs = { _id: '1', agent: { type: ['blah'] }, + host: { name: ['some host name'] }, kibana: { alert: { workflow_status: ['open'], @@ -31,32 +40,207 @@ const ecsRowData: Ecs = { }, }; -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/lib/apm/use_start_transaction'); -jest.mock('../../../../common/hooks/use_app_toasts'); -jest.mock('../actions'); +const nonECSRowData: TimelineEventsDetailsItem[] = [ + { + category: 'agent', + isObjectArray: false, + field: 'agent.type', + values: ['blah'], + }, + { + category: 'kibana', + isObjectArray: false, + field: 'kibana.alert.workflow_status', + values: ['open'], + }, + { + category: 'kibana', + isObjectArray: false, + field: 'kibana.alert.rule.uuid', + values: ['testId'], + }, + { + category: 'host', + isObjectArray: false, + field: 'host.name', + values: ['some host name'], + }, +]; -(KibanaServices.get as jest.Mock).mockReturnValue(coreMock.createStart()); -const mockSendAlertToTimeline = jest.spyOn(actions, 'sendAlertToTimelineAction'); -(useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - search: { - searchStrategyClient: jest.fn(), +const getEcsDataWithRuleTypeAndTimelineTemplate = (ruleType: string, ecsData: Ecs = ecsRowData) => { + return { + ...ecsData, + kibana: { + ...(ecsData?.kibana ?? {}), + alert: { + ...(ecsData.kibana?.alert ?? {}), + rule: { + ...(ecsData.kibana?.alert.rule ?? {}), + type: [ruleType], + timeline_id: ['dummyTimelineTemplateId'], + }, }, - query: jest.fn(), }, - }, -}); + } as Ecs; +}; + +const getNonEcsDataWithRuleTypeAndTimelineTemplate = ( + ruleType: string, + nonEcsData: TimelineEventsDetailsItem[] = nonECSRowData +) => { + return [ + ...nonEcsData, + { + category: 'kibana', + isObjectArray: false, + field: 'kibana.alert.rule.type', + values: [ruleType], + }, + { + category: 'kibana', + isObjectArray: false, + field: 'kibana.alert.rule.timeline_id', + values: ['dummyTimelineTemplateId'], + }, + ]; +}; + +const mockSendAlertToTimeline = jest.spyOn(actions, 'sendAlertToTimelineAction'); + (useAppToasts as jest.Mock).mockReturnValue({ addError: jest.fn(), }); +const mockTimelineTemplateResponse = { + data: { + getOneTimeline: { + savedObjectId: '15bc8185-06ef-4956-b7e7-be8e289b13c2', + version: 'WzIzMzUsMl0=', + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + type: 'date', + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + }, + ], + dataProviders: [ + { + and: [], + enabled: true, + id: 'some-random-id', + name: 'host.name', + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: '{host.name}', + operator: ':', + }, + type: 'template', + }, + ], + dataViewId: 'security-solution-default', + description: '', + eqlOptions: { + eventCategoryField: 'event.category', + tiebreakerField: '', + timestampField: '@timestamp', + query: '', + size: 100, + }, + eventType: 'all', + excludedRowRendererIds: [ + 'alert', + 'alerts', + 'auditd', + 'auditd_file', + 'library', + 'netflow', + 'plain', + 'registry', + 'suricata', + 'system', + 'system_dns', + 'system_endgame_process', + 'system_file', + 'system_fim', + 'system_security_event', + 'system_socket', + 'threat_match', + 'zeek', + ], + favorite: [], + filters: [], + indexNames: ['.alerts-security.alerts-default', 'auditbeat-*', 'filebeat-*', 'packetbeat-*'], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '*', + }, + serializedQuery: '{"query_string":{"query":"*"}}', + }, + }, + title: 'Named Template', + templateTimelineId: 'c755cda6-8a65-4ec2-b6ff-35a5356de8b9', + templateTimelineVersion: 1, + dateRange: { + start: '2024-08-13T22:00:00.000Z', + end: '2024-08-14T21:59:59.999Z', + }, + savedQueryId: null, + created: 1723625359467, + createdBy: 'elastic', + updated: 1723625359988, + updatedBy: 'elastic', + timelineType: 'template', + status: 'active', + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + sortDirection: 'desc', + esTypes: ['date'], + }, + ], + savedSearchId: null, + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + }, +}; + const props = { ecsRowData, - onInvestigateInTimelineAlertClick: () => {}, + onInvestigateInTimelineAlertClick: jest.fn(), }; +const addTimelineSpy = jest.spyOn(timelineActions, 'addTimeline'); + +const RULE_TYPES_TO_BE_TESTED = [ + 'query', + 'esql', + 'eql', + 'machine_learning', + /* TODO: Complete test suites for below rule types */ + // 'new_terms', + // 'eql', + // 'threshold', + // 'threat_match', +]; + const renderContextMenu = (items: AlertTableContextMenuItem[]) => { const panels = [{ id: 0, items }]; return render( @@ -72,11 +256,28 @@ const renderContextMenu = (items: AlertTableContextMenuItem[]) => { ); }; -describe('use investigate in timeline hook', () => { +describe('useInvestigateInTimeline', () => { + let mockSearchStrategyClient = { + search: jest + .fn() + .mockReturnValue(of({ data: getNonEcsDataWithRuleTypeAndTimelineTemplate('query') })), + }; + beforeEach(() => { + (getTimelineTemplate as jest.Mock).mockResolvedValue(mockTimelineTemplateResponse); + // by default we return data for query rule type + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + search: mockSearchStrategyClient, + query: jest.fn(), + }, + }, + }); + }); afterEach(() => { jest.clearAllMocks(); }); - test('it creates a component and click handler', () => { + test('creates a component and click handler', () => { const { result } = renderHook(() => useInvestigateInTimeline(props), { wrapper: TestProviders, }); @@ -98,4 +299,101 @@ describe('use investigate in timeline hook', () => { expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(1); }); }); + + describe('investigate an alert with timeline template', () => { + describe.each(RULE_TYPES_TO_BE_TESTED)('Rule type : %s', (ruleType: string) => { + test('should copy columns over from template', async () => { + mockSearchStrategyClient = { + search: jest + .fn() + .mockReturnValue(of({ data: getNonEcsDataWithRuleTypeAndTimelineTemplate(ruleType) })), + }; + const ecsData = getEcsDataWithRuleTypeAndTimelineTemplate(ruleType); + const { result } = renderHook( + () => useInvestigateInTimeline({ ...props, ecsRowData: ecsData }), + { + wrapper: TestProviders, + } + ); + + const expectedColumns = [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + type: 'date', + initialWidth: 215, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: undefined, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: undefined, + }, + ]; + + const investigateAction = result.current.investigateInTimelineAlertClick; + await investigateAction(); + + await waitFor(() => { + expect(addTimelineSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + timeline: expect.objectContaining({ + columns: expectedColumns, + }), + }) + ); + }); + }); + test('should copy dataProviders over from template', async () => { + mockSearchStrategyClient = { + search: jest + .fn() + .mockReturnValue(of({ data: getNonEcsDataWithRuleTypeAndTimelineTemplate(ruleType) })), + }; + const ecsData: Ecs = getEcsDataWithRuleTypeAndTimelineTemplate(ruleType); + const { result } = renderHook( + () => useInvestigateInTimeline({ ...props, ecsRowData: ecsData }), + { + wrapper: TestProviders, + } + ); + + const expectedDataProvider = [ + { + and: [], + enabled: true, + id: 'some-random-id', + name: 'some host name', + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'some host name', + operator: ':', + }, + type: 'default', + }, + ]; + + const investigateAction = result.current.investigateInTimelineAlertClick; + await investigateAction(); + + await waitFor(() => { + expect(addTimelineSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + timeline: expect.objectContaining({ + dataProviders: expectedDataProvider, + }), + }) + ); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index 2056de93a63be..211bfd0db2dc1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -18,6 +18,7 @@ import { useApi } from '@kbn/securitysolution-list-hooks'; import type { Filter } from '@kbn/es-query'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { isEmpty } from 'lodash'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers'; import { useKibana } from '../../../../common/lib/kibana'; @@ -147,10 +148,19 @@ export const useInvestigateInTimeline = ({ const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( 'unifiedComponentsInTimelineDisabled' ); + const updateTimeline = useUpdateTimeline(); const createTimeline = useCallback( async ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { + const newColumns = timeline.columns; + const newColumnsOverride = + !newColumns || isEmpty(newColumns) + ? !unifiedComponentsInTimelineDisabled + ? defaultUdtHeaders + : defaultHeaders + : newColumns; + await clearActiveTimeline(); updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); updateTimeline({ @@ -160,7 +170,7 @@ export const useInvestigateInTimeline = ({ notes: [], timeline: { ...timeline, - columns: !unifiedComponentsInTimelineDisabled ? defaultUdtHeaders : defaultHeaders, + columns: newColumnsOverride, indexNames: timeline.indexNames ?? [], show: true, excludedRowRendererIds: