From 8eb931986ddbb86581b88b463ec7363cf93cfdf2 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 19 Mar 2020 10:27:41 -0600 Subject: [PATCH] [SIEM] [Cases] Case closed and add user email (#60463) --- .../siem/public/containers/case/types.ts | 7 +- .../public/containers/case/use_get_case.tsx | 2 + .../containers/case/use_update_case.tsx | 4 +- .../components/all_cases/__mock__/index.tsx | 14 +- .../case/components/all_cases/columns.tsx | 50 +++-- .../pages/case/components/all_cases/index.tsx | 18 +- .../case/components/all_cases/translations.ts | 6 - .../case/components/case_status/index.tsx | 105 +++++++++++ .../components/case_view/__mock__/index.tsx | 50 ++--- .../components/case_view/actions.test.tsx | 65 +++++++ .../case/components/case_view/actions.tsx | 75 ++++++++ .../case/components/case_view/index.test.tsx | 95 ++++------ .../pages/case/components/case_view/index.tsx | 171 +++++------------- .../case/components/case_view/translations.ts | 16 ++ .../case/components/user_list/index.test.tsx | 40 ++++ .../pages/case/components/user_list/index.tsx | 28 ++- .../siem/public/pages/case/translations.ts | 10 + x-pack/plugins/case/common/api/cases/case.ts | 2 + x-pack/plugins/case/common/api/user.ts | 1 + .../routes/api/__fixtures__/authc_mock.ts | 6 +- .../api/__fixtures__/mock_saved_objects.ts | 50 +++++ .../api/cases/comments/patch_comment.ts | 4 +- .../api/cases/configure/patch_configure.ts | 4 +- .../api/cases/configure/post_configure.ts | 4 +- .../routes/api/cases/find_cases.test.ts | 2 +- .../routes/api/cases/patch_cases.test.ts | 50 ++++- .../server/routes/api/cases/patch_cases.ts | 30 ++- .../plugins/case/server/routes/api/types.ts | 2 +- .../plugins/case/server/routes/api/utils.ts | 20 +- .../case/server/saved_object_types/cases.ts | 22 +++ .../server/saved_object_types/comments.ts | 6 + 31 files changed, 692 insertions(+), 267 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 65d94865bf00ca..5b6ff8438be8c9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -18,6 +18,8 @@ export interface Comment { export interface Case { id: string; + closedAt: string | null; + closedBy: ElasticUser | null; comments: Comment[]; commentIds: string[]; createdAt: string; @@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', - updatedAt = 'updatedAt', + closedAt = 'closedAt', } export interface ElasticUser { - readonly username: string; + readonly email?: string | null; readonly fullName?: string | null; + readonly username: string; } export interface FetchCasesProps { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index a179b6f546b9bc..b70195e2c126f5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; const initialData: Case = { id: '', + closedAt: null, + closedBy: null, createdAt: '', comments: [], commentIds: [], diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index afcbe20fa791ad..987620469901bd 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -5,7 +5,7 @@ */ import { useReducer, useCallback } from 'react'; - +import { cloneDeep } from 'lodash/fp'; import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { errorToToaster, useStateToaster } from '../../components/toasters'; @@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - caseData: action.payload, + caseData: cloneDeep(action.payload), updateKey: null, }; case 'FETCH_FAILURE': diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 0fe8daafcb30ab..5d00b770b3ca9c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -13,6 +13,8 @@ export const useGetCasesMockState: UseGetCasesState = { countOpenCases: 0, cases: [ { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, @@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, @@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, @@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: '2020-02-13T19:44:13.328Z', + closedBy: { username: 'elastic' }, id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, @@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = { status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: null, - updatedBy: null, + updatedAt: '2020-02-13T19:44:13.328Z', + updatedBy: { username: 'elastic' }, version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 5859e6bbce263f..b9e1113c486ad9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -36,7 +36,8 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = ( - actions: Array> + actions: Array>, + filterStatus: string ): CasesColumns[] => [ { name: i18n.NAME, @@ -113,22 +114,39 @@ export const getCasesColumns = ( render: (comments: Case['commentIds']) => renderStringField(`${comments.length}`, `case-table-column-commentCount`), }, - { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - ); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, } - return getEmptyTagValue(); - }, - }, + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 9f836bd043c9dc..9a84dd07b0af44 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -71,8 +71,8 @@ const ProgressLoader = styled(EuiProgress)` const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; - } else if (field === SortFieldCase.updatedAt) { - return SortFieldCase.updatedAt; + } else if (field === SortFieldCase.closedAt) { + return SortFieldCase.closedAt; } return SortFieldCase.createdAt; }; @@ -206,17 +206,25 @@ export const AllCases = React.memo(() => { } setQueryParams(newQueryParams); }, - [setQueryParams, queryParams] + [queryParams] ); const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt }); + } setFilters({ ...filterOptions, ...newFilterOptions }); }, - [filterOptions, setFilters] + [filterOptions, queryParams] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [ + actions, + filterOptions.status, + ]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index 27532e57166e1d..8f79b78ef7568b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -60,9 +60,3 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { defaultMessage: 'Delete', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx new file mode 100644 index 00000000000000..9dbd71ea3e34c2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -0,0 +1,105 @@ +/* + * 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 React, { useCallback } from 'react'; +import styled, { css } from 'styled-components'; +import { + EuiBadge, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import * as i18n from '../case_view/translations'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseViewActions } from '../case_view/actions'; + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +interface CaseStatusProps { + 'data-test-subj': string; + badgeColor: string; + buttonLabel: string; + caseId: string; + caseTitle: string; + icon: string; + isLoading: boolean; + isSelected: boolean; + status: string; + title: string; + toggleStatusCase: (status: string) => void; + value: string | null; +} +const CaseStatusComp: React.FC = ({ + 'data-test-subj': dataTestSubj, + badgeColor, + buttonLabel, + caseId, + caseTitle, + icon, + isLoading, + isSelected, + status, + title, + toggleStatusCase, + value, +}) => { + const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ + toggleStatusCase, + ]); + return ( + + + + + + {i18n.STATUS} + + + {status} + + + + + {title} + + + + + + + + + + + + + + + + + + + ); +}; + +export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 53cc1f80b5c108..e11441eac3a9d2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ @@ -20,6 +22,7 @@ export const caseProps: CaseProps = { createdBy: { fullName: 'Steph Milovic', username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', }, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { @@ -29,7 +32,7 @@ export const caseProps: CaseProps = { }, ], createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, username: 'elastic' }, + createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', status: 'open', tags: ['defacement'], @@ -41,35 +44,22 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; - -export const data: Case = { - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - }, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', +export const caseClosedProps: CaseProps = { + ...caseProps, + initialData: { + ...caseProps.initialData, + closedAt: '2020-02-20T23:06:33.798Z', + closedBy: { + username: 'elastic', }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { username: 'elastic', fullName: null }, - description: 'Security banana Issue', - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', + status: 'closed', }, - version: 'WzQ3LDFd', +}; + +export const data: Case = { + ...caseProps.initialData, +}; + +export const dataClosed: Case = { + ...caseClosedProps.initialData, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx new file mode 100644 index 00000000000000..4e1e5ba753c364 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 React from 'react'; +import { mount } from 'enzyme'; +import { CaseViewActions } from './actions'; +import { TestProviders } from '../../../../mock'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +jest.mock('../../../../containers/case/use_delete_cases'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; + +describe('CaseView actions', () => { + const caseTitle = 'Cool title'; + const caseId = 'cool-id'; + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); + const dispatchResetIsDeleted = jest.fn(); + const defaultDeleteState = { + dispatchResetIsDeleted, + handleToggleModal, + handleOnDeleteConfirm, + isLoading: false, + isError: false, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + }; + beforeEach(() => { + jest.resetAllMocks(); + useDeleteCasesMock.mockImplementation(() => defaultDeleteState); + }); + it('clicking trash toggles modal', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); + + wrapper + .find('button[data-test-subj="property-actions-ellipses"]') + .first() + .simulate('click'); + wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); + expect(handleToggleModal).toHaveBeenCalled(); + }); + it('toggle delete modal and confirm', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteState, + isDisplayConfirmDeleteModal: true, + })); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx new file mode 100644 index 00000000000000..88a717ac5fa6a9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx @@ -0,0 +1,75 @@ +/* + * 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 React, { useMemo } from 'react'; + +import { Redirect } from 'react-router-dom'; +import * as i18n from './translations'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { SiemPageName } from '../../../home/types'; +import { PropertyActions } from '../property_actions'; + +interface CaseViewActions { + caseId: string; + caseTitle: string; +} + +const CaseViewActionsComponent: React.FC = ({ caseId, caseTitle }) => { + // Delete case + const { + handleToggleModal, + handleOnDeleteConfirm, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); + + const confirmDeleteModal = useMemo( + () => ( + + ), + [isDisplayConfirmDeleteModal] + ); + // TO DO refactor each of these const's into their own components + const propertyActions = useMemo( + () => [ + { + iconType: 'trash', + label: i18n.DELETE_CASE, + onClick: handleToggleModal, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ], + [handleToggleModal] + ); + + if (isDeleted) { + return ; + } + return ( + <> + + {confirmDeleteModal} + + ); +}; + +export const CaseViewActions = React.memo(CaseViewActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 15d6cf7cf7317a..ec18bdb2bf9abe 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -7,15 +7,13 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseComponent } from './'; -import * as updateHook from '../../../../containers/case/use_update_case'; -import * as deleteHook from '../../../../containers/case/use_delete_cases'; -import { caseProps, data } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +jest.mock('../../../../containers/case/use_update_case'); +const useUpdateCaseMock = useUpdateCase as jest.Mock; describe('CaseView ', () => { - const handleOnDeleteConfirm = jest.fn(); - const handleToggleModal = jest.fn(); - const dispatchResetIsDeleted = jest.fn(); const updateCaseProperty = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() @@ -28,15 +26,17 @@ describe('CaseView ', () => { }); /* eslint-enable no-console */ + const defaultUpdateCaseState = { + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; + beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(updateHook, 'useUpdateCase').mockReturnValue({ - caseData: data, - isLoading: false, - isError: false, - updateKey: null, - updateCaseProperty, - }); + useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); }); it('should render CaseComponent', () => { @@ -69,6 +69,7 @@ describe('CaseView ', () => { .first() .text() ).toEqual(data.createdBy.username); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); expect( wrapper .find(`[data-test-subj="case-view-createdAt"]`) @@ -82,6 +83,30 @@ describe('CaseView ', () => { .prop('raw') ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: dataClosed, + })); + const wrapper = mount( + + + + ); + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect( + wrapper + .find(`[data-test-subj="case-view-closedAt"]`) + .first() + .prop('value') + ).toEqual(dataClosed.closedAt); + expect( + wrapper + .find(`[data-test-subj="case-view-status"]`) + .first() + .text() + ).toEqual(dataClosed.status); + }); it('should dispatch update state when button is toggled', () => { const wrapper = mount( @@ -92,7 +117,7 @@ describe('CaseView ', () => { wrapper .find('input[data-test-subj="toggle-case-status"]') - .simulate('change', { target: { value: false } }); + .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ updateKey: 'status', @@ -133,46 +158,4 @@ describe('CaseView ', () => { .prop('source') ).toEqual(data.comments[0].comment); }); - - it('toggle delete modal and cancel', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - - wrapper - .find( - '[data-test-subj="case-view-actions"] button[data-test-subj="property-actions-ellipses"]' - ) - .first() - .simulate('click'); - wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalCancelButton"]').simulate('click'); - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); - }); - - it('toggle delete modal and confirm', () => { - jest.spyOn(deleteHook, 'useDeleteCases').mockReturnValue({ - dispatchResetIsDeleted, - handleToggleModal, - handleOnDeleteConfirm, - isLoading: false, - isError: false, - isDeleted: false, - isDisplayConfirmDeleteModal: true, - }); - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); - wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); - expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseProps.caseId]); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 82216e88a091e7..dce7bde2225c92 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -5,26 +5,14 @@ */ import React, { useCallback, useMemo } from 'react'; -import { - EuiBadge, - EuiButtonToggle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import styled, { css } from 'styled-components'; -import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; -import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; @@ -33,23 +21,13 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; -import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; -import { SiemPageName } from '../../../home/types'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useBasePath } from '../../../../lib/kibana'; +import { CaseStatus } from '../case_status'; interface Props { caseId: string; } -const MyDescriptionList = styled(EuiDescriptionList)` - ${({ theme }) => css` - & { - padding-right: ${theme.eui.euiSizeL}; - border-right: ${theme.eui.euiBorderThin}; - } - `} -`; - const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; @@ -64,6 +42,8 @@ export interface CaseProps { } export const CaseComponent = React.memo(({ caseId, initialData }) => { + const basePath = window.location.origin + useBasePath(); + const caseLink = `${basePath}/app/siem#/case/${caseId}`; const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -107,58 +87,44 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [updateCaseProperty, caseData.status] - ); - const toggleStatusCase = useCallback( - e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), - [onUpdateField] + [caseData.status] ); - const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); - - // Delete case - const { - handleToggleModal, - handleOnDeleteConfirm, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - const confirmDeleteModal = useMemo( - () => ( - - ), - [isDisplayConfirmDeleteModal] + const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); + + const caseStatusData = useMemo( + () => + caseData.status === 'open' + ? { + 'data-test-subj': 'case-view-createdAt', + value: caseData.createdAt, + title: i18n.CASE_OPENED, + buttonLabel: i18n.CLOSE_CASE, + status: caseData.status, + icon: 'checkInCircleFilled', + badgeColor: 'secondary', + isSelected: false, + } + : { + 'data-test-subj': 'case-view-closedAt', + value: caseData.closedAt, + title: i18n.CASE_CLOSED, + buttonLabel: i18n.REOPEN_CASE, + status: caseData.status, + icon: 'magnet', + badgeColor: 'danger', + isSelected: true, + }, + [caseData.closedAt, caseData.createdAt, caseData.status] + ); + const emailContent = useMemo( + () => ({ + subject: i18n.EMAIL_SUBJECT(caseData.title), + body: i18n.EMAIL_BODY(caseLink), + }), + [caseData.title] ); - // TO DO refactor each of these const's into their own components - const propertyActions = [ - { - iconType: 'trash', - label: 'Delete case', - onClick: handleToggleModal, - }, - { - iconType: 'popout', - label: 'View ServiceNow incident', - onClick: () => null, - }, - { - iconType: 'importAction', - label: 'Update ServiceNow incident', - onClick: () => null, - }, - ]; - - if (isDeleted) { - return ; - } - return ( <> @@ -177,51 +143,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } title={caseData.title} > - - - - - - {i18n.STATUS} - - - {caseData.status} - - - - - {i18n.CASE_OPENED} - - - - - - - - - - - - - - - - - - + @@ -237,6 +165,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -250,7 +179,6 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - {confirmDeleteModal} ); }); @@ -273,4 +201,5 @@ export const CaseView = React.memo(({ caseId }: Props) => { return ; }); +CaseComponent.displayName = 'CaseComponent'; CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 82b5e771e21513..e5fa3bff51f852 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); + +export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const EMAIL_SUBJECT = (caseTitle: string) => + i18n.translate('xpack.siem.case.caseView.emailSubject', { + values: { caseTitle }, + defaultMessage: 'SIEM Case - {caseTitle}', + }); + +export const EMAIL_BODY = (caseUrl: string) => + i18n.translate('xpack.siem.case.caseView.emailBody', { + values: { caseUrl }, + defaultMessage: 'Case reference: {caseUrl}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx new file mode 100644 index 00000000000000..51acb3b810d92e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; +import { UserList } from './'; +import * as i18n from '../case_view/translations'; + +describe('UserList ', () => { + const title = 'Case Title'; + const caseLink = 'http://reddit.com'; + const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; + const open = jest.fn(); + beforeAll(() => { + window.open = open; + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('triggers mailto when email icon clicked', () => { + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click'); + expect(open).toBeCalledWith( + `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`, + '_blank' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index abb49122dc1421..74a1b98c29eefa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiText, @@ -17,6 +17,10 @@ import styled, { css } from 'styled-components'; import { ElasticUser } from '../../../../containers/case/types'; interface UserListProps { + email: { + subject: string; + body: string; + }; headline: string; users: ElasticUser[]; } @@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -const renderUsers = (users: ElasticUser[]) => { - return users.map(({ fullName, username }, key) => ( +const renderUsers = ( + users: ElasticUser[], + handleSendEmail: (emailAddress: string | undefined | null) => void +) => { + return users.map(({ fullName, username, email }, key) => ( @@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => { {}} // TO DO + data-test-subj="user-list-email-button" + onClick={handleSendEmail.bind(null, email)} // TO DO iconType="email" aria-label="email" /> @@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => { )); }; -export const UserList = React.memo(({ headline, users }: UserListProps) => { +export const UserList = React.memo(({ email, headline, users }: UserListProps) => { + const handleSendEmail = useCallback( + (emailAddress: string | undefined | null) => { + if (emailAddress && emailAddress != null) { + window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank'); + } + }, + [email.subject] + ); return (

{headline}

- {renderUsers(users)} + {renderUsers(users, handleSendEmail)}
); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 6ef412d408ae5d..341a34240fe499 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { defaultMessage: 'Opened on', }); +export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { + defaultMessage: 'Closed on', +}); +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { + defaultMessage: 'Reopen case', +}); +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { + defaultMessage: 'Close case', +}); + export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', }); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 68a222cb656ed0..6f58e2702ec5bd 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ comment_ids: rt.array(rt.string), + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, updated_at: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts index ed44791c4e04d2..651cd08f08a021 100644 --- a/x-pack/plugins/case/common/api/user.ts +++ b/x-pack/plugins/case/common/api/user.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; export const UserRT = rt.type({ + email: rt.union([rt.undefined, rt.string]), full_name: rt.union([rt.undefined, rt.string]), username: rt.string, }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 17a25184826378..c08dae1dc18b49 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -13,7 +13,11 @@ function createAuthenticationMock({ authc.getCurrentUser.mockReturnValue( currentUser !== undefined ? currentUser - : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser) + : ({ + email: 'd00d@awesome.com', + username: 'awesome', + full_name: 'Awesome D00d', + } as AuthenticatedUser) ); return authc; } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 1e1965f83ff684..5aa8b93f17b080 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -12,10 +12,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-1', attributes: { + closed_at: null, + closed_by: null, comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', @@ -25,6 +28,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -36,10 +40,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-2', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', @@ -49,6 +56,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -60,10 +68,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-3', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -73,6 +84,39 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, + { + type: 'cases', + id: 'mock-id-4', + attributes: { + closed_at: '2019-11-25T22:32:17.947Z', + closed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comment_ids: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + status: 'closed', + tags: ['LOLBins'], + updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -100,11 +144,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:00.177Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -126,11 +172,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:14.633Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -153,11 +201,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T22:32:30.608Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 0166ba89eb76c8..c14a94e84e51c3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, updatedAttributes: { comment: query.comment, updated_at: new Date().toISOString(), - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version: query.version, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1da1161ab01d19..1542394fc438db 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ @@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout updatedAttributes: { ...queryWithoutVersion, updated_at: updateDate, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index a22dd8437e5087..c839d36dcf4dfb 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ @@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route attributes: { ...query, created_at: creationDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7ce37d2569e573..8fafb1af0eb826 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -34,6 +34,6 @@ describe('GET all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases).toHaveLength(3); + expect(response.payload.cases).toHaveLength(4); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 7ab7212d2f436e..19ff7f0734a777 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -25,7 +25,7 @@ describe('PATCH cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Patch a case`, async () => { + it(`Close a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -50,17 +50,61 @@ describe('PATCH cases', () => { expect(response.status).toEqual(200); expect(response.payload).toEqual([ { + closed_at: '2019-11-25T21:54:48.952Z', + closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', username: 'elastic' }, + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { full_name: 'Awesome D00d', username: 'awesome' }, + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }, + ]); + }); + it(`Open a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-4', + status: 'open', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual([ + { + closed_at: null, + closed_by: null, + comment_ids: [], + comments: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + id: 'mock-id-4', + status: 'open', + tags: ['LOLBins'], + title: 'Another bad one', + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', }, ]); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 3fd8c2a1627ab9..4aa0d8daf5b34a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseIds: query.cases.map(q => q.id), }); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); + + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } return myCase == null || myCase?.version !== q.version; }); + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map(c => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } if (conflictedCases.length > 0) { throw Boom.conflict( `These cases ${conflictedCases @@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }); if (updateFilterCases.length > 0) { const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: context.core.savedObjects.client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } return { caseId, updatedAttributes: { ...updateCaseAttributes, + ...closedInfo, updated_at: updatedDt, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version, }; diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index eac259cc69c5a6..7af3e7b70d96fa 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -14,7 +14,7 @@ export interface RouteDeps { } export enum SortFieldCase { + closedAt = 'closed_at', createdAt = 'created_at', status = 'status', - updatedAt = 'updated_at', } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 27ee6fc58e20a5..19dbb024d1e0be 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,18 +26,22 @@ import { SortFieldCase } from './types'; export const transformNewCase = ({ createdDate, - newCase, + email, full_name, + newCase, username, }: { createdDate: string; - newCase: CaseRequest; + email?: string; full_name?: string; + newCase: CaseRequest; username: string; }): CaseAttributes => ({ + closed_at: newCase.status === 'closed' ? createdDate : null, + closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, comment_ids: [], created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, ...newCase, @@ -46,18 +50,20 @@ export const transformNewCase = ({ interface NewCommentArgs { comment: string; createdDate: string; + email?: string; full_name?: string; username: string; } export const transformNewComment = ({ comment, createdDate, + email, full_name, username, }: NewCommentArgs): CommentAttributes => ({ comment, created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }); @@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => { case 'createdAt': case 'created_at': return SortFieldCase.createdAt; - case 'updatedAt': - case 'updated_at': - return SortFieldCase.updatedAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; default: return SortFieldCase.createdAt; } diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 2aa64528739b10..8eab040b9ca9cd 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, comment_ids: { type: 'keyword', }, @@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, description: { @@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 51c31421fec2fd..f52da886e7611b 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { username: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, updated_at: { @@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, },