From 456aba12b3bfd758249c690115a6c432e9a88455 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Wed, 6 Mar 2024 14:55:26 +0100 Subject: [PATCH] [SecuritySolution] Add timeline middleware tests (#178009) ## Summary In the previous work for https://github.com/elastic/kibana/issues/175427, we replaced redux-observable with plain redux middlewares. The code that was based on redux-observable wasn't tested, so as part of the refactoring we're now adding tests to all timeline middlewares in this PR. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios (cherry picked from commit 8f390002702accb40539d3a97ea0f0d43fdedcd5) --- .../middlewares/timeline_changed.test.ts | 108 ++++++++++++ .../store/middlewares/timeline_changed.ts | 2 +- .../middlewares/timeline_favorite.test.ts | 143 +++++++++++++++ .../store/middlewares/timeline_note.test.ts | 136 +++++++++++++++ .../middlewares/timeline_pinned_event.test.ts | 127 ++++++++++++++ .../store/middlewares/timeline_save.test.ts | 164 +++++++++++++++++- .../store/middlewares/timeline_save.ts | 4 +- 7 files changed, 679 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.test.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_favorite.test.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_pinned_event.test.ts diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.test.ts new file mode 100644 index 0000000000000..ac8412f2fe8f5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMockStore } from '../../../common/mock'; +import { selectTimelineById } from '../selectors'; +import { TimelineId } from '../../../../common/types/timeline'; + +import { + setChanged, + updateKqlMode, + showTimeline, + applyKqlFilterQuery, + addProvider, + dataProviderEdited, + removeColumn, + removeProvider, + updateColumns, + updateEqlOptions, + updateDataProviderEnabled, + updateDataProviderExcluded, + updateDataProviderType, + updateProviders, + updateRange, + updateSort, + upsertColumn, + updateDataView, + updateTitleAndDescription, + setExcludedRowRendererIds, + setFilters, + setSavedQueryId, + updateSavedSearch, +} from '../actions'; +import { timelineChangedTypes } from './timeline_changed'; + +jest.mock('../actions', () => { + const actual = jest.requireActual('../actions'); + return { + ...actual, + setChanged: jest.fn().mockImplementation((...args) => actual.setChanged(...args)), + }; +}); + +/** + * This is a copy of the timeline changed types from the actual middleware. + * The purpose of this copy is to enforce changes to the original to fail. + * These changes will need to be applied to the copy to pass the tests. + * That way, we are preventing accidental changes to the original. + */ +const timelineChangedTypesCopy = [ + applyKqlFilterQuery.type, + addProvider.type, + dataProviderEdited.type, + removeProvider.type, + setExcludedRowRendererIds.type, + setFilters.type, + setSavedQueryId.type, + updateDataProviderEnabled.type, + updateDataProviderExcluded.type, + updateDataProviderType.type, + updateEqlOptions.type, + updateKqlMode.type, + updateProviders.type, + updateTitleAndDescription.type, + + updateDataView.type, + removeColumn.type, + updateColumns.type, + updateSort.type, + updateRange.type, + upsertColumn.type, + + updateSavedSearch.type, +]; + +const setChangedMock = setChanged as unknown as jest.Mock; + +describe('Timeline changed middleware', () => { + let store = createMockStore(); + + beforeEach(() => { + store = createMockStore(); + setChangedMock.mockClear(); + }); + + it('should mark a timeline as changed for some actions', () => { + expect(selectTimelineById(store.getState(), TimelineId.test).kqlMode).toEqual('filter'); + + store.dispatch(updateKqlMode({ id: TimelineId.test, kqlMode: 'search' })); + + expect(setChangedMock).toHaveBeenCalledWith({ id: TimelineId.test, changed: true }); + expect(selectTimelineById(store.getState(), TimelineId.test).kqlMode).toEqual('search'); + }); + + it('should check that all correct actions are used to check for changes', () => { + timelineChangedTypesCopy.forEach((changedType) => { + expect(timelineChangedTypes.has(changedType)).toBeTruthy(); + }); + }); + + it('should not mark a timeline as changed for some actions', () => { + store.dispatch(showTimeline({ id: TimelineId.test, show: true })); + expect(setChangedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.ts index aac6fa71b4467..97b579c475e33 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_changed.ts @@ -36,7 +36,7 @@ import { /** * All action types that will mark a timeline as changed */ -const timelineChangedTypes = new Set([ +export const timelineChangedTypes = new Set([ applyKqlFilterQuery.type, addProvider.type, dataProviderEdited.type, diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_favorite.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_favorite.test.ts new file mode 100644 index 0000000000000..50a0ed53c4913 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_favorite.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMockStore, kibanaMock } from '../../../common/mock'; +import { selectTimelineById } from '../selectors'; +import { persistFavorite } from '../../containers/api'; +import { TimelineId } from '../../../../common/types/timeline'; +import { refreshTimelines } from './helpers'; + +import { + startTimelineSaving, + endTimelineSaving, + updateIsFavorite, + showCallOutUnauthorizedMsg, + updateTimeline, +} from '../actions'; + +jest.mock('../actions', () => { + const actual = jest.requireActual('../actions'); + const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args)); + (endTLSaving as unknown as { match: Function }).match = () => false; + return { + ...actual, + showCallOutUnauthorizedMsg: jest + .fn() + .mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)), + startTimelineSaving: jest + .fn() + .mockImplementation((...args) => actual.startTimelineSaving(...args)), + endTimelineSaving: endTLSaving, + }; +}); +jest.mock('../../containers/api'); +jest.mock('./helpers'); + +const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock; +const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock; +const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock; + +describe('Timeline favorite middleware', () => { + let store = createMockStore(undefined, undefined, kibanaMock); + const newVersion = 'new_version'; + const newSavedObjectId = 'new_so_id'; + + beforeEach(() => { + store = createMockStore(undefined, undefined, kibanaMock); + jest.clearAllMocks(); + }); + + it('should persist a timeline favorite when a favorite action is dispatched', async () => { + (persistFavorite as jest.Mock).mockResolvedValue({ + data: { + persistFavorite: { + code: 200, + favorite: [{}], + savedObjectId: newSavedObjectId, + version: newVersion, + }, + }, + }); + expect(selectTimelineById(store.getState(), TimelineId.test).isFavorite).toEqual(false); + await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: true })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual( + expect.objectContaining({ + isFavorite: true, + savedObjectId: newSavedObjectId, + version: newVersion, + }) + ); + }); + + it('should persist a timeline un-favorite when a favorite action is dispatched for a favorited timeline', async () => { + store.dispatch( + updateTimeline({ + id: TimelineId.test, + timeline: { + ...selectTimelineById(store.getState(), TimelineId.test), + isFavorite: true, + }, + }) + ); + (persistFavorite as jest.Mock).mockResolvedValue({ + data: { + persistFavorite: { + code: 200, + favorite: [], + savedObjectId: newSavedObjectId, + version: newVersion, + }, + }, + }); + expect(selectTimelineById(store.getState(), TimelineId.test).isFavorite).toEqual(true); + await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: false })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual( + expect.objectContaining({ + isFavorite: false, + savedObjectId: newSavedObjectId, + version: newVersion, + }) + ); + }); + + it('should show an error message when the call is unauthorized', async () => { + (persistFavorite as jest.Mock).mockResolvedValue({ + data: { + persistFavorite: { + code: 403, + }, + }, + }); + + await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: true })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled(); + }); + + it('should show a generic error when the persistence throws', async () => { + const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); + (persistFavorite as jest.Mock).mockImplementation(() => { + throw new Error(); + }); + + await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: true })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(addDangerMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts new file mode 100644 index 0000000000000..96aa0e58cc716 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_note.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMockStore, kibanaMock } from '../../../common/mock'; +import { selectTimelineById } from '../selectors'; +import { TimelineId } from '../../../../common/types/timeline'; +import { persistNote } from '../../containers/notes/api'; +import { refreshTimelines } from './helpers'; + +import { + startTimelineSaving, + endTimelineSaving, + showCallOutUnauthorizedMsg, + addNote, + addNoteToEvent, +} from '../actions'; +import { updateNote } from '../../../common/store/app/actions'; +import { createNote } from '../../components/notes/helpers'; + +jest.mock('../actions', () => { + const actual = jest.requireActual('../actions'); + const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args)); + (endTLSaving as unknown as { match: Function }).match = () => false; + return { + ...actual, + showCallOutUnauthorizedMsg: jest + .fn() + .mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)), + startTimelineSaving: jest + .fn() + .mockImplementation((...args) => actual.startTimelineSaving(...args)), + endTimelineSaving: endTLSaving, + }; +}); +jest.mock('../../containers/notes/api'); +jest.mock('./helpers'); + +const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock; +const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock; +const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock; + +describe('Timeline note middleware', () => { + let store = createMockStore(undefined, undefined, kibanaMock); + const testNote = createNote({ newNote: 'test', user: 'elastic' }); + const testEventId = 'test'; + + beforeEach(() => { + store = createMockStore(undefined, undefined, kibanaMock); + jest.clearAllMocks(); + }); + + it('should persist a timeline note', async () => { + (persistNote as jest.Mock).mockResolvedValue({ + data: { + persistNote: { + code: 200, + message: 'success', + note: { + noteId: testNote.id, + }, + }, + }, + }); + expect(selectTimelineById(store.getState(), TimelineId.test).noteIds).toEqual([]); + await store.dispatch(updateNote({ note: testNote })); + await store.dispatch(addNote({ id: TimelineId.test, noteId: testNote.id })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test).noteIds).toContain(testNote.id); + }); + + it('should persist a note on an event of a timeline', async () => { + (persistNote as jest.Mock).mockResolvedValue({ + data: { + persistNote: { + code: 200, + message: 'success', + note: { + noteId: testNote.id, + }, + }, + }, + }); + expect(selectTimelineById(store.getState(), TimelineId.test).eventIdToNoteIds).toEqual({}); + await store.dispatch(updateNote({ note: testNote })); + await store.dispatch( + addNoteToEvent({ eventId: testEventId, id: TimelineId.test, noteId: testNote.id }) + ); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test).eventIdToNoteIds).toEqual( + expect.objectContaining({ + [testEventId]: [testNote.id], + }) + ); + }); + + it('should show an error message when the call is unauthorized', async () => { + (persistNote as jest.Mock).mockResolvedValue({ + data: { + persistNote: { + code: 403, + }, + }, + }); + + await store.dispatch(updateNote({ note: testNote })); + await store.dispatch(addNote({ id: TimelineId.test, noteId: testNote.id })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled(); + }); + + it('should show a generic error when the persistence throws', async () => { + const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); + (persistNote as jest.Mock).mockImplementation(() => { + throw new Error(); + }); + + await store.dispatch(updateNote({ note: testNote })); + await store.dispatch(addNote({ id: TimelineId.test, noteId: testNote.id })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(addDangerMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_pinned_event.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_pinned_event.test.ts new file mode 100644 index 0000000000000..3a6673886a9a9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_pinned_event.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMockStore, kibanaMock } from '../../../common/mock'; +import { selectTimelineById } from '../selectors'; +import { TimelineId } from '../../../../common/types/timeline'; +import { persistPinnedEvent } from '../../containers/pinned_event/api'; +import { refreshTimelines } from './helpers'; + +import { + startTimelineSaving, + endTimelineSaving, + pinEvent, + unPinEvent, + showCallOutUnauthorizedMsg, + updateTimeline, +} from '../actions'; + +jest.mock('../actions', () => { + const actual = jest.requireActual('../actions'); + const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args)); + (endTLSaving as unknown as { match: Function }).match = () => false; + return { + ...actual, + showCallOutUnauthorizedMsg: jest + .fn() + .mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)), + startTimelineSaving: jest + .fn() + .mockImplementation((...args) => actual.startTimelineSaving(...args)), + endTimelineSaving: endTLSaving, + }; +}); +jest.mock('../../containers/pinned_event/api'); +jest.mock('./helpers'); + +const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock; +const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock; +const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock; + +describe('Timeline pinned event middleware', () => { + let store = createMockStore(undefined, undefined, kibanaMock); + const testEventId = 'test'; + + beforeEach(() => { + store = createMockStore(undefined, undefined, kibanaMock); + jest.clearAllMocks(); + }); + + it('should persist a timeline pin event action', async () => { + (persistPinnedEvent as jest.Mock).mockResolvedValue({ + data: { + persistPinnedEventOnTimeline: { + code: 200, + }, + }, + }); + expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({}); + await store.dispatch(pinEvent({ id: TimelineId.test, eventId: testEventId })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({ + [testEventId]: true, + }); + }); + + it('should persist a timeline un-pin event', async () => { + store.dispatch( + updateTimeline({ + id: TimelineId.test, + timeline: { + ...selectTimelineById(store.getState(), TimelineId.test), + pinnedEventIds: { + [testEventId]: true, + }, + }, + }) + ); + (persistPinnedEvent as jest.Mock).mockResolvedValue({ + data: {}, + }); + expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({ + [testEventId]: true, + }); + await store.dispatch(unPinEvent({ id: TimelineId.test, eventId: testEventId })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({}); + }); + + it('should show an error message when the call is unauthorized', async () => { + (persistPinnedEvent as jest.Mock).mockResolvedValue({ + data: { + persistPinnedEventOnTimeline: { + code: 403, + }, + }, + }); + + await store.dispatch(unPinEvent({ id: TimelineId.test, eventId: testEventId })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled(); + }); + + it('should show a generic error when the persistence throws', async () => { + const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); + (persistPinnedEvent as jest.Mock).mockImplementation(() => { + throw new Error(); + }); + + await store.dispatch(pinEvent({ id: TimelineId.test, eventId: testEventId })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(addDangerMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts index a74bf55d7ff19..3e57a7b9f28bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.test.ts @@ -8,12 +8,172 @@ import type { Filter } from '@kbn/es-query'; import { FilterStateStore } from '@kbn/es-query'; import { Direction } from '../../../../common/search_strategy'; -import { TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; import { TimelineType, TimelineStatus } from '../../../../common/api/timeline'; import { convertTimelineAsInput } from './timeline_save'; import type { TimelineModel } from '../model'; +import { createMockStore, kibanaMock } from '../../../common/mock'; +import { selectTimelineById } from '../selectors'; +import { copyTimeline, persistTimeline } from '../../containers/api'; +import { refreshTimelines } from './helpers'; +import * as i18n from '../../pages/translations'; + +import { + startTimelineSaving, + endTimelineSaving, + showCallOutUnauthorizedMsg, + saveTimeline, + setChanged, +} from '../actions'; + +jest.mock('../actions', () => { + const actual = jest.requireActual('../actions'); + const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args)); + (endTLSaving as unknown as { match: Function }).match = () => false; + return { + ...actual, + showCallOutUnauthorizedMsg: jest + .fn() + .mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)), + startTimelineSaving: jest + .fn() + .mockImplementation((...args) => actual.startTimelineSaving(...args)), + endTimelineSaving: endTLSaving, + }; +}); +jest.mock('../../containers/api'); +jest.mock('./helpers'); + +const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock; +const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock; +const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock; + +describe('Timeline save middleware', () => { + let store = createMockStore(undefined, undefined, kibanaMock); + + beforeEach(() => { + store = createMockStore(undefined, undefined, kibanaMock); + jest.clearAllMocks(); + }); + + it('should persist a timeline', async () => { + (persistTimeline as jest.Mock).mockResolvedValue({ + data: { + persistTimeline: { + code: 200, + message: 'success', + timeline: { + savedObjectId: 'soid', + version: 'newVersion', + }, + }, + }, + }); + await store.dispatch(setChanged({ id: TimelineId.test, changed: true })); + expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual( + expect.objectContaining({ + version: null, + changed: true, + }) + ); + await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false })); + + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(persistTimeline as unknown as jest.Mock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual( + expect.objectContaining({ + version: 'newVersion', + changed: false, + }) + ); + }); + + it('should copy a timeline', async () => { + (copyTimeline as jest.Mock).mockResolvedValue({ + data: { + persistTimeline: { + code: 200, + message: 'success', + timeline: { + savedObjectId: 'soid', + version: 'newVersion', + }, + }, + }, + }); + await store.dispatch(setChanged({ id: TimelineId.test, changed: true })); + expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual( + expect.objectContaining({ + version: null, + changed: true, + }) + ); + await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: true })); + + expect(copyTimeline as unknown as jest.Mock).toHaveBeenCalled(); + expect(startTimelineSavingMock).toHaveBeenCalled(); + expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled(); + expect(endTimelineSavingMock).toHaveBeenCalled(); + expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual( + expect.objectContaining({ + version: 'newVersion', + changed: false, + }) + ); + }); + + it('should show an error message in case of a conflict', async () => { + const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); + (copyTimeline as jest.Mock).mockResolvedValue({ + status_code: 409, + message: 'test conflict', + }); + await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: true })); + + expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled(); + expect(addDangerMock).toHaveBeenCalledWith({ + title: i18n.TIMELINE_VERSION_CONFLICT_TITLE, + text: i18n.TIMELINE_VERSION_CONFLICT_DESCRIPTION, + }); + }); + + it('should show the provided message in case of an error response', async () => { + const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); + (persistTimeline as jest.Mock).mockResolvedValue({ + status_code: 404, + message: 'test error message', + }); + await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false })); + + expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled(); + expect(addDangerMock).toHaveBeenCalledWith({ + title: i18n.UPDATE_TIMELINE_ERROR_TITLE, + text: 'test error message', + }); + }); + + it('should show a generic error in case of an empty response', async () => { + const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger'); + (persistTimeline as jest.Mock).mockResolvedValue(null); + await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false })); + + expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled(); + expect(addDangerMock).toHaveBeenCalledWith({ + title: i18n.UPDATE_TIMELINE_ERROR_TITLE, + text: i18n.UPDATE_TIMELINE_ERROR_TEXT, + }); + }); + + it('should show an error message when the call is unauthorized', async () => { + (persistTimeline as jest.Mock).mockResolvedValue({ data: { persistTimeline: { code: 403 } } }); + await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false })); + + expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled(); + expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled(); + }); -describe('Timeline Save Middleware', () => { describe('#convertTimelineAsInput ', () => { test('should return a TimelineInput instead of TimelineModel ', () => { const columns: TimelineModel['columns'] = [ diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts index 39b5203a2e395..e8d1e335ab569 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts @@ -99,7 +99,7 @@ export const saveTimelineMiddleware: (kibana: CoreStart) => Middleware<{}, State return; } - const response = result.data.persistTimeline; + const response = result?.data?.persistTimeline; if (response == null) { kibana.notifications.toasts.addDanger({ title: i18n.UPDATE_TIMELINE_ERROR_TITLE, @@ -273,7 +273,7 @@ const convertToString = (obj: unknown) => { type PossibleResponse = TimelineResponse | TimelineErrorResponse; function isTimelineErrorResponse(response: PossibleResponse): response is TimelineErrorResponse { - return 'status_code' in response || 'statusCode' in response; + return response && ('status_code' in response || 'statusCode' in response); } function getErrorFromResponse(response: TimelineErrorResponse) {