diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts new file mode 100644 index 000000000000..e262d12770d3 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { timeline } from '../objects/timeline'; + +import { + FAVORITE_TIMELINE, + LOCKED_ICON, + NOTES, + NOTES_BUTTON, + NOTES_COUNT, + NOTES_TEXT_AREA, + TIMELINE_DESCRIPTION, + // TIMELINE_FILTER, + TIMELINE_QUERY, + TIMELINE_TITLE, +} from '../screens/timeline'; +import { + TIMELINES_DESCRIPTION, + TIMELINES_PINNED_EVENT_COUNT, + TIMELINES_NOTES_COUNT, + TIMELINES_FAVORITE, +} from '../screens/timelines'; + +import { loginAndWaitForPage } from '../tasks/login'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { + addDescriptionToTimeline, + addFilter, + addNameToTimeline, + addNotesToTimeline, + closeNotes, + closeTimeline, + createNewTimelineTemplate, + markAsFavorite, + openTimelineFromSettings, + populateTimeline, + waitForTimelineChanges, +} from '../tasks/timeline'; +import { openTimeline } from '../tasks/timelines'; + +import { OVERVIEW_URL } from '../urls/navigation'; + +describe('Timeline Templates', () => { + before(() => { + cy.server(); + cy.route('PATCH', '**/api/timeline').as('timeline'); + }); + + it('Creates a timeline template', async () => { + loginAndWaitForPage(OVERVIEW_URL); + openTimelineUsingToggle(); + createNewTimelineTemplate(); + populateTimeline(); + addFilter(timeline.filter); + // To fix + // cy.get(PIN_EVENT).should( + // 'have.attr', + // 'aria-label', + // 'This event may not be pinned while editing a template timeline' + // ); + cy.get(LOCKED_ICON).should('be.visible'); + + addNameToTimeline(timeline.title); + + const response = await cy.wait('@timeline').promisify(); + const timelineId = JSON.parse(response.xhr.responseText).data.persistTimeline.timeline + .savedObjectId; + + addDescriptionToTimeline(timeline.description); + addNotesToTimeline(timeline.notes); + closeNotes(); + markAsFavorite(); + waitForTimelineChanges(); + createNewTimelineTemplate(); + closeTimeline(); + openTimelineFromSettings(); + + cy.contains(timeline.title).should('exist'); + cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description); + cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_FAVORITE).first().should('exist'); + + openTimeline(timelineId); + + cy.get(FAVORITE_TIMELINE).should('exist'); + cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title); + cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', timeline.description); + cy.get(TIMELINE_QUERY).should('have.text', timeline.query); + // Comments this assertion until we agreed what to do with the filters. + // cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); + cy.get(NOTES_COUNT).should('have.text', '1'); + cy.get(NOTES_BUTTON).click(); + cy.get(NOTES_TEXT_AREA).should('have.attr', 'placeholder', 'Add a Note'); + cy.get(NOTES).should('have.text', timeline.notes); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts new file mode 100644 index 000000000000..8dcb5e144c24 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { exportTimeline } from '../tasks/timelines'; +import { esArchiverLoad } from '../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { timeline as timelineTemplate } from '../objects/timeline'; + +import { TIMELINE_TEMPLATES_URL } from '../urls/navigation'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { addNameToTimeline, closeTimeline, createNewTimelineTemplate } from '../tasks/timeline'; + +describe('Export timelines', () => { + before(() => { + esArchiverLoad('timeline'); + cy.server(); + cy.route('PATCH', '**/api/timeline').as('timeline'); + cy.route('POST', '**api/timeline/_export?file_name=timelines_export.ndjson*').as('export'); + }); + + it('Exports a custom timeline template', async () => { + loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL); + openTimelineUsingToggle(); + createNewTimelineTemplate(); + addNameToTimeline(timelineTemplate.title); + closeTimeline(); + + const result = await cy.wait('@timeline').promisify(); + + const timelineId = JSON.parse(result.xhr.responseText).data.persistTimeline.timeline + .savedObjectId; + const templateTimelineId = JSON.parse(result.xhr.responseText).data.persistTimeline.timeline + .templateTimelineId; + + await exportTimeline(timelineId); + + cy.wait('@export').then((response) => { + cy.wrap(JSON.parse(response.xhr.responseText).templateTimelineId).should( + 'eql', + templateTimelineId + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 94255a2af897..e397dd9b5a41 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -29,6 +29,8 @@ export const COMBO_BOX = '.euiComboBoxOption__content'; export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; +export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline-new"]'; + export const DRAGGABLE_HEADER = '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"] [data-test-subj="draggable-header"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 438700bdfca8..7c9c95427a4d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -43,6 +43,7 @@ import { TIMELINE_TITLE, TIMESTAMP_TOGGLE_FIELD, TOGGLE_TIMELINE_EXPAND_EVENT, + CREATE_NEW_TIMELINE_TEMPLATE, } from '../screens/timeline'; import { TIMELINES_TABLE } from '../screens/timelines'; @@ -114,6 +115,11 @@ export const createNewTimeline = () => { cy.get(CLOSE_TIMELINE_BTN).click({ force: true }); }; +export const createNewTimelineTemplate = () => { + cy.get(TIMELINE_SETTINGS_ICON).click({ force: true }); + cy.get(CREATE_NEW_TIMELINE_TEMPLATE).click(); +}; + export const executeTimelineKQL = (query: string) => { cy.get(`${SEARCH_OR_FILTER_CONTAINER} textarea`).type(`${query} {enter}`); }; diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index b53b06db5bed..66b9bf8e111a 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -20,3 +20,4 @@ export const ADMINISTRATION_URL = '/app/security/administration'; export const NETWORK_URL = '/app/security/network'; export const OVERVIEW_URL = '/app/security/overview'; export const TIMELINES_URL = '/app/security/timelines'; +export const TIMELINE_TEMPLATES_URL = '/app/security/timelines/template'; diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx index 33e26cd4db03..c8efc366643e 100644 --- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx @@ -71,9 +71,11 @@ export const GenericDownloaderComponent = ({ anchorRef.current.href = objectURL; // eslint-disable-line require-atomic-updates anchorRef.current.download = filename; // eslint-disable-line require-atomic-updates anchorRef.current.click(); - window.URL.revokeObjectURL(objectURL); - } + if (typeof window.URL.revokeObjectURL === 'function') { + window.URL.revokeObjectURL(objectURL); + } + } if (onExportSuccess != null) { onExportSuccess(ids.length); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx index b8a7cfd59d22..d0cfbaccde7d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -5,9 +5,15 @@ */ import React from 'react'; +import { useStateToaster } from '../../../../common/components/toasters'; + import { TimelineDownloader } from './export_timeline'; import { mockSelectedTimeline } from './mocks'; +import * as i18n from '../translations'; + import { ReactWrapper, mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { useParams } from 'react-router-dom'; jest.mock('../translations', () => { return { @@ -22,6 +28,23 @@ jest.mock('.', () => { }; }); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + + return { + ...actual, + useParams: jest.fn(), + }; +}); + +jest.mock('../../../../common/components/toasters', () => { + const actual = jest.requireActual('../../../../common/components/toasters'); + return { + ...actual, + useStateToaster: jest.fn(), + }; +}); + describe('TimelineDownloader', () => { let wrapper: ReactWrapper; const defaultTestProps = { @@ -30,6 +53,20 @@ describe('TimelineDownloader', () => { isEnableDownloader: true, onComplete: jest.fn(), }; + const mockDispatchToaster = jest.fn(); + + beforeEach(() => { + (useStateToaster as jest.Mock).mockReturnValue([jest.fn(), mockDispatchToaster]); + (useParams as jest.Mock).mockReturnValue({ tabName: 'default' }); + }); + + afterEach(() => { + (useStateToaster as jest.Mock).mockClear(); + (useParams as jest.Mock).mockReset(); + + (mockDispatchToaster as jest.Mock).mockClear(); + }); + describe('should not render a downloader', () => { test('Without exportedIds', () => { const testProps = { @@ -59,5 +96,38 @@ describe('TimelineDownloader', () => { wrapper = mount(); expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); }); + + test('With correct toast message on success for exported timelines', async () => { + const testProps = { + ...defaultTestProps, + }; + + await act(() => { + wrapper = mount(); + }); + + wrapper.update(); + + expect(mockDispatchToaster.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_EXPORTED_TIMELINES + ); + }); + + test('With correct toast message on success for exported templates', async () => { + const testProps = { + ...defaultTestProps, + }; + (useParams as jest.Mock).mockReturnValue({ tabName: 'template' }); + + await act(() => { + wrapper = mount(); + }); + + wrapper.update(); + + expect(mockDispatchToaster.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_EXPORTED_TIMELINES + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx index e6eec7198d11..cc62d29cde34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx @@ -6,12 +6,15 @@ import React, { useCallback } from 'react'; import uuid from 'uuid'; +import { useParams } from 'react-router-dom'; + import { GenericDownloader, ExportSelectedData, } from '../../../../common/components/generic_downloader'; import * as i18n from '../translations'; import { useStateToaster } from '../../../../common/components/toasters'; +import { TimelineType } from '../../../../../common/types/timeline'; const ExportTimeline: React.FC<{ exportedIds: string[] | undefined; @@ -20,22 +23,28 @@ const ExportTimeline: React.FC<{ onComplete?: () => void; }> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { const [, dispatchToaster] = useStateToaster(); + const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); + const onExportSuccess = useCallback( (exportCount) => { if (onComplete != null) { onComplete(); } + dispatchToaster({ type: 'addToaster', toast: { id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + title: + timelineType === TimelineType.template + ? i18n.SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES(exportCount) + : i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), color: 'success', iconType: 'check', }, }); }, - [dispatchToaster, onComplete] + [dispatchToaster, onComplete, timelineType] ); const onExportFailure = useCallback(() => { if (onComplete != null) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index 2d5849463270..07f22bd47a9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -22,6 +22,15 @@ import { TimelineType, TimelineStatus } from '../../../../common/types/timeline' jest.mock('../../../common/lib/kibana'); +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + + return { + ...actual, + useParams: jest.fn().mockReturnValue({ tabName: 'default' }), + }; +}); + describe('OpenTimeline', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index bad2fe7e02e1..3f391714bb05 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -270,6 +270,16 @@ export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', }); +export const SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES = (totalTimelineTemplates: number) => + i18n.translate( + 'xpack.securitySolution.open.timeline.successfullyExportedTimelineTemplatesTitle', + { + values: { totalTimelineTemplates }, + defaultMessage: + 'Successfully exported {totalTimelineTemplates, plural, =0 {all timelines} =1 {{totalTimelineTemplates} timeline template} other {{totalTimelineTemplates} timeline templates}}', + } + ); + export const FILTER_TIMELINES = (timelineType: string) => i18n.translate('xpack.securitySolution.open.timeline.filterByTimelineTypesTitle', { values: { timelineType }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx index 27780c7754d0..df7fd7befb81 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx @@ -29,7 +29,7 @@ export const Pin = React.memo( const isTemplate = timelineType === TimelineType.template; return (