[Security Solution] Update export timeline success message by exported timelines' type (#79469)

* init tests

* fix export on success message

* add cypress tests

* fix unit tests

* fix unit tests

* Update x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2020-10-06 18:52:44 +01:00 committed by GitHub
parent 8cae9ef25b
commit 8cab902482
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 261 additions and 5 deletions

View file

@ -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);
});
});

View file

@ -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
);
});
});
});

View file

@ -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"]';

View file

@ -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}`);
};

View file

@ -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';

View file

@ -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);
}

View file

@ -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(<TimelineDownloader {...testProps} />);
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(<TimelineDownloader {...testProps} />);
});
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(<TimelineDownloader {...testProps} />);
});
wrapper.update();
expect(mockDispatchToaster.mock.calls[0][0].title).toEqual(
i18n.SUCCESSFULLY_EXPORTED_TIMELINES
);
});
});
});

View file

@ -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) {

View file

@ -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';

View file

@ -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 },

View file

@ -29,7 +29,7 @@ export const Pin = React.memo<Props>(
const isTemplate = timelineType === TimelineType.template;
return (
<EuiButtonIcon
aria-label={pinned ? i18n.PINNED : i18n.UNPINNED}
aria-label={isTemplate ? i18n.DISABLE_PIN : pinned ? i18n.PINNED : i18n.UNPINNED}
data-test-subj="pin"
iconSize={iconSize}
iconType={getPinIcon(pinned)}