diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 4f74f9ff2f5d6..b6548e3e950ba 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -44,7 +44,10 @@ const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailNam children, detailName, }) => ( - + {children ? children : detailName} ); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index 8eb08bd3d62f0..e1b3951a2317d 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -67,7 +67,7 @@ describe('SIEM Navigation', () => { detailName: undefined, navTabs: { case: { - disabled: true, + disabled: false, href: '#/link-to/case', id: 'case', name: 'Case', @@ -160,7 +160,7 @@ describe('SIEM Navigation', () => { filters: [], navTabs: { case: { - disabled: true, + disabled: false, href: '#/link-to/case', id: 'case', name: 'Case', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 830e00c70975e..bff3bfd62a85c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -5,12 +5,12 @@ */ import { KibanaServices } from '../../lib/kibana'; -import { AllCases, FetchCasesProps, Case, NewCase, SortFieldCase } from './types'; -import { Direction } from '../../graphql/types'; +import { FetchCasesProps, Case, NewCase, SortFieldCase, AllCases, CaseSnake } from './types'; import { throwIfNotOk } from '../../hooks/api/api'; import { CASES_URL } from './constants'; +import { convertToCamelCase, convertAllCasesToCamel } from './utils'; -export const getCase = async (caseId: string, includeComments: boolean) => { +export const getCase = async (caseId: string, includeComments: boolean): Promise => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'GET', asResponse: true, @@ -19,7 +19,7 @@ export const getCase = async (caseId: string, includeComments: boolean) => { }, }); await throwIfNotOk(response.response); - return response.body!; + return convertToCamelCase(response.body!); }; export const getCases = async ({ @@ -31,7 +31,7 @@ export const getCases = async ({ page: 1, perPage: 20, sortField: SortFieldCase.createdAt, - sortOrder: Direction.desc, + sortOrder: 'desc', }, }: FetchCasesProps): Promise => { const tags = [...(filterOptions.tags?.map(t => `case-workflow.attributes.tags: ${t}`) ?? [])]; @@ -46,7 +46,7 @@ export const getCases = async ({ asResponse: true, }); await throwIfNotOk(response.response); - return response.body!; + return convertAllCasesToCamel(response.body!); }; export const createCase = async (newCase: NewCase): Promise => { @@ -56,18 +56,19 @@ export const createCase = async (newCase: NewCase): Promise => { body: JSON.stringify(newCase), }); await throwIfNotOk(response.response); - return response.body!; + return convertToCamelCase(response.body!); }; export const updateCaseProperty = async ( caseId: string, - updatedCase: Partial + updatedCase: Partial, + version: string ): Promise> => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'PATCH', asResponse: true, - body: JSON.stringify(updatedCase), + body: JSON.stringify({ case: updatedCase, version }), }); await throwIfNotOk(response.response); - return response.body!; + return convertToCamelCase, Partial>(response.body!); }; 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 0f80b2327a30c..1aea0b0f50a89 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Direction } from '../../graphql/types'; interface FormData { isNew?: boolean; } @@ -15,22 +14,35 @@ export interface NewCase extends FormData { title: string; } -export interface Case { +export interface CaseSnake { case_id: string; created_at: string; - created_by: ElasticUser; + created_by: ElasticUserSnake; description: string; state: string; tags: string[]; title: string; updated_at: string; + version?: string; +} + +export interface Case { + caseId: string; + createdAt: string; + createdBy: ElasticUser; + description: string; + state: string; + tags: string[]; + title: string; + updatedAt: string; + version?: string; } export interface QueryParams { page: number; perPage: number; sortField: SortFieldCase; - sortOrder: Direction; + sortOrder: 'asc' | 'desc'; } export interface FilterOptions { @@ -38,21 +50,33 @@ export interface FilterOptions { tags: string[]; } +export interface AllCasesSnake { + cases: CaseSnake[]; + page: number; + per_page: number; + total: number; +} + export interface AllCases { cases: Case[]; page: number; - per_page: number; + perPage: number; total: number; } export enum SortFieldCase { - createdAt = 'created_at', + createdAt = 'createdAt', state = 'state', - updatedAt = 'updated_at', + updatedAt = 'updatedAt', +} + +export interface ElasticUserSnake { + readonly username: string; + readonly full_name?: string | null; } export interface ElasticUser { readonly username: string; - readonly full_name?: string; + readonly fullName?: string | null; } 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 8cc961c68fdf0..bf76b69ef22d6 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 @@ -50,16 +50,16 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { } }; const initialData: Case = { - case_id: '', - created_at: '', - created_by: { + caseId: '', + createdAt: '', + createdBy: { username: '', }, description: '', state: '', tags: [], title: '', - updated_at: '', + updatedAt: '', }; export const useGetCase = (caseId: string): [CaseState] => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index db9c07747ba04..4037823ccfc94 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -17,7 +17,6 @@ import { } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams } from './types'; import { getTypedPayload } from './utils'; -import { Direction } from '../../graphql/types'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../components/toasters'; import * as i18n from './translations'; @@ -31,16 +30,9 @@ export interface UseGetCasesState { filterOptions: FilterOptions; } -export interface QueryArgs { - page?: number; - perPage?: number; - sortField?: SortFieldCase; - sortOrder?: Direction; -} - export interface Action { type: string; - payload?: AllCases | QueryArgs | FilterOptions; + payload?: AllCases | Partial | FilterOptions; } const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { switch (action.type) { @@ -83,13 +75,13 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS const initialData: AllCases = { page: 0, - per_page: 0, + perPage: 0, total: 0, cases: [], }; export const useGetCases = (): [ UseGetCasesState, - Dispatch>, + Dispatch>>, Dispatch> ] => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -104,11 +96,11 @@ export const useGetCases = (): [ page: DEFAULT_TABLE_ACTIVE_PAGE, perPage: DEFAULT_TABLE_LIMIT, sortField: SortFieldCase.createdAt, - sortOrder: Direction.desc, + sortOrder: 'desc', }, }); - const [queryParams, setQueryParams] = useState(state.queryParams as QueryArgs); - const [filterQuery, setFilters] = useState(state.filterOptions as FilterOptions); + const [queryParams, setQueryParams] = useState>(state.queryParams); + const [filterQuery, setFilters] = useState(state.filterOptions); const [, dispatchToaster] = useStateToaster(); useEffect(() => { 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 68592c17e58dc..62e3d87b528c0 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 @@ -96,7 +96,11 @@ export const useUpdateCase = ( const updateData = async (updateKey: keyof Case) => { dispatch({ type: FETCH_INIT }); try { - const response = await updateCaseProperty(caseId, { [updateKey]: state.data[updateKey] }); + const response = await updateCaseProperty( + caseId, + { [updateKey]: state.data[updateKey] }, + state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true + ); dispatch({ type: FETCH_SUCCESS, payload: response }); } catch (error) { errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8e6eaca1a8f0c..14a3819bdfdad 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -4,4 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ +import { camelCase, isArray, isObject, set } from 'lodash'; +import { AllCases, AllCasesSnake, Case, CaseSnake } from './types'; + export const getTypedPayload = (a: unknown): T => a as T; + +export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => + arrayOfSnakes.reduce((acc: unknown[], value) => { + if (isArray(value)) { + return [...acc, convertArrayToCamelCase(value)]; + } else if (isObject(value)) { + return [...acc, convertToCamelCase(value)]; + } else { + return [...acc, value]; + } + }, []); + +export const convertToCamelCase = (snakeCase: T): U => + Object.entries(snakeCase).reduce((acc, [key, value]) => { + if (isArray(value)) { + set(acc, camelCase(key), convertArrayToCamelCase(value)); + } else if (isObject(value)) { + set(acc, camelCase(key), convertToCamelCase(value)); + } else { + set(acc, camelCase(key), value); + } + return acc; + }, {} as U); + +export const convertAllCasesToCamel = (snakeCases: AllCasesSnake): AllCases => ({ + cases: snakeCases.cases.map(snakeCase => convertToCamelCase(snakeCase)), + page: snakeCases.page, + perPage: snakeCases.per_page, + total: snakeCases.total, +}); 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 new file mode 100644 index 0000000000000..98a67304fcf1f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -0,0 +1,77 @@ +/* + * 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 { SortFieldCase } from '../../../../../containers/case/types'; +import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; + +export const useGetCasesMockState: UseGetCasesState = { + data: { + cases: [ + { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:23.627Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['defacement'], + title: 'Another horrible breach', + updatedAt: '2020-02-13T19:44:23.627Z', + }, + { + caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:13.328Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['phishing'], + title: 'Bad email', + updatedAt: '2020-02-13T19:44:13.328Z', + }, + { + caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:11.328Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['phishing'], + title: 'Bad email', + updatedAt: '2020-02-13T19:44:11.328Z', + }, + { + caseId: '31890e90-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:05.563Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'closed', + tags: ['phishing'], + title: 'Uh oh', + updatedAt: '2020-02-18T21:32:24.056Z', + }, + { + caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:01.901Z', + createdBy: { username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['phishing'], + title: 'Uh oh', + updatedAt: '2020-02-13T19:44:01.901Z', + }, + ], + page: 1, + perPage: 5, + total: 10, + }, + isLoading: false, + isError: false, + queryParams: { + page: 1, + perPage: 5, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', + }, + filterOptions: { search: '', tags: [] }, +}; 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 92cd16fd2000e..4c47bf605051d 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 @@ -14,14 +14,15 @@ import * as i18n from './translations'; export type CasesColumns = EuiTableFieldDataColumnType | EuiTableComputedColumnType; -const renderStringField = (field: string) => (field != null ? field : getEmptyTagValue()); +const renderStringField = (field: string, dataTestSubj: string) => + field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = (): CasesColumns[] => [ { name: i18n.CASE_TITLE, render: (theCase: Case) => { - if (theCase.case_id != null && theCase.title != null) { - return {theCase.title}; + if (theCase.caseId != null && theCase.title != null) { + return {theCase.title}; } return getEmptyTagValue(); }, @@ -34,7 +35,11 @@ export const getCasesColumns = (): CasesColumns[] => [ return ( {tags.map((tag: string, i: number) => ( - + {tag} ))} @@ -46,28 +51,39 @@ export const getCasesColumns = (): CasesColumns[] => [ truncateText: true, }, { - field: 'created_at', + field: 'createdAt', name: i18n.CREATED_AT, sortable: true, - render: (createdAt: Case['created_at']) => { + render: (createdAt: Case['createdAt']) => { if (createdAt != null) { - return ; + return ( + + ); } return getEmptyTagValue(); }, }, { - field: 'created_by.username', + field: 'createdBy.username', name: i18n.REPORTER, - render: (createdBy: Case['created_by']['username']) => renderStringField(createdBy), + render: (createdBy: Case['createdBy']['username']) => + renderStringField(createdBy, `case-table-column-username`), }, { - field: 'updated_at', + field: 'updatedAt', name: i18n.LAST_UPDATED, sortable: true, - render: (updatedAt: Case['updated_at']) => { + render: (updatedAt: Case['updatedAt']) => { if (updatedAt != null) { - return ; + return ( + + ); } return getEmptyTagValue(); }, @@ -76,6 +92,6 @@ export const getCasesColumns = (): CasesColumns[] => [ field: 'state', name: i18n.STATE, sortable: true, - render: (state: Case['state']) => renderStringField(state), + render: (state: Case['state']) => renderStringField(state, `case-table-column-state`), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx new file mode 100644 index 0000000000000..5a87cf53142f7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -0,0 +1,97 @@ +/* + * 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 moment from 'moment-timezone'; +import { AllCases } from './'; +import { TestProviders } from '../../../../mock'; +import { useGetCasesMockState } from './__mock__'; +import * as apiHook from '../../../../containers/case/use_get_cases'; + +describe('AllCases', () => { + const setQueryParams = jest.fn(); + const setFilters = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + jest + .spyOn(apiHook, 'useGetCases') + .mockReturnValue([useGetCasesMockState, setQueryParams, setFilters]); + moment.tz.setDefault('UTC'); + }); + it('should render AllCases', () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`a[data-test-subj="case-details-link"]`) + .first() + .prop('href') + ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].caseId}`); + expect( + wrapper + .find(`a[data-test-subj="case-details-link"]`) + .first() + .text() + ).toEqual(useGetCasesMockState.data.cases[0].title); + expect( + wrapper + .find(`[data-test-subj="case-table-column-state"]`) + .first() + .text() + ).toEqual(useGetCasesMockState.data.cases[0].state); + expect( + wrapper + .find(`span[data-test-subj="case-table-column-tags-0"]`) + .first() + .prop('title') + ).toEqual(useGetCasesMockState.data.cases[0].tags[0]); + expect( + wrapper + .find(`[data-test-subj="case-table-column-username"]`) + .first() + .text() + ).toEqual(useGetCasesMockState.data.cases[0].createdBy.username); + expect( + wrapper + .find(`[data-test-subj="case-table-column-createdAt"]`) + .first() + .prop('value') + ).toEqual(useGetCasesMockState.data.cases[0].createdAt); + expect( + wrapper + .find(`[data-test-subj="case-table-column-updatedAt"]`) + .first() + .prop('value') + ).toEqual(useGetCasesMockState.data.cases[0].updatedAt); + + expect( + wrapper + .find(`[data-test-subj="case-table-case-count"]`) + .first() + .text() + ).toEqual('Showing 10 cases'); + }); + it('should tableHeaderSortButton AllCases', () => { + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="tableHeaderCell_state_5"] [data-test-subj="tableHeaderSortButton"]') + .simulate('click'); + expect(setQueryParams).toBeCalledWith({ + page: 1, + perPage: 5, + sortField: 'state', + sortOrder: 'asc', + }); + }); +}); 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 b1dd39c95e191..3253a036c2990 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 @@ -18,7 +18,6 @@ import * as i18n from './translations'; import { getCasesColumns } from './columns'; import { SortFieldCase, Case, FilterOptions } from '../../../../containers/case/types'; -import { Direction } from '../../../../graphql/types'; import { useGetCases } from '../../../../containers/case/use_get_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; import { Panel } from '../../../../components/panel'; @@ -32,7 +31,16 @@ import { UtilityBarText, } from '../../../../components/detection_engine/utility_bar'; import { getCreateCaseUrl } from '../../../../components/link_to'; - +const getSortField = (field: string): SortFieldCase => { + if (field === SortFieldCase.createdAt) { + return SortFieldCase.createdAt; + } else if (field === SortFieldCase.state) { + return SortFieldCase.state; + } else if (field === SortFieldCase.updatedAt) { + return SortFieldCase.updatedAt; + } + return SortFieldCase.createdAt; +}; export const AllCases = React.memo(() => { const [ { data, isLoading, queryParams, filterOptions }, @@ -44,24 +52,10 @@ export const AllCases = React.memo(() => { ({ page, sort }: EuiBasicTableOnChange) => { let newQueryParams = queryParams; if (sort) { - let newSort; - switch (sort.field) { - case 'state': - newSort = SortFieldCase.state; - break; - case 'created_at': - newSort = SortFieldCase.createdAt; - break; - case 'updated_at': - newSort = SortFieldCase.updatedAt; - break; - default: - newSort = SortFieldCase.createdAt; - } newQueryParams = { ...newQueryParams, - sortField: newSort, - sortOrder: sort.direction as Direction, + sortField: getSortField(sort.field), + sortOrder: sort.direction, }; } if (page) { @@ -114,7 +108,9 @@ export const AllCases = React.memo(() => { - {i18n.SHOWING_CASES(data.total ?? 0)} + + {i18n.SHOWING_CASES(data.total ?? 0)} + 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 new file mode 100644 index 0000000000000..7480c4fc4bb2a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -0,0 +1,34 @@ +/* + * 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 { CaseProps } from '../index'; +import { Case } from '../../../../../containers/case/types'; + +export const caseProps: CaseProps = { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + initialData: { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:23.627Z', + createdBy: { fullName: null, username: 'elastic' }, + description: 'Security banana Issue', + state: 'open', + tags: ['defacement'], + title: 'Another horrible breach!!', + updatedAt: '2020-02-19T15:02:57.995Z', + }, + isLoading: false, +}; + +export const data: Case = { + caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + createdAt: '2020-02-13T19:44:23.627Z', + createdBy: { username: 'elastic', fullName: null }, + description: 'Security banana Issue', + state: 'open', + tags: ['defacement'], + title: 'Another horrible breach!!', + updatedAt: '2020-02-19T15:02:57.995Z', +}; 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 new file mode 100644 index 0000000000000..a9e694bad705d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { CaseComponent } from './'; +import * as apiHook from '../../../../containers/case/use_update_case'; +import { caseProps, data } from './__mock__'; +import { TestProviders } from '../../../../mock'; + +describe('CaseView ', () => { + const dispatchUpdateCaseProperty = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue([{ data }, dispatchUpdateCaseProperty]); + }); + + it('should render CaseComponent', () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-test-subj="case-view-title"]`) + .first() + .prop('title') + ).toEqual(data.title); + expect( + wrapper + .find(`[data-test-subj="case-view-state"]`) + .first() + .text() + ).toEqual(data.state); + expect( + wrapper + .find(`[data-test-subj="case-view-tag-list"] .euiBadge__text`) + .first() + .text() + ).toEqual(data.tags[0]); + expect( + wrapper + .find(`[data-test-subj="case-view-username"]`) + .first() + .text() + ).toEqual(data.createdBy.username); + expect( + wrapper + .find(`[data-test-subj="case-view-createdAt"]`) + .first() + .prop('value') + ).toEqual(data.createdAt); + expect( + wrapper + .find(`[data-test-subj="case-view-description"]`) + .first() + .prop('raw') + ).toEqual(data.description); + }); + + it('should dispatch update state when button is toggled', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('input[data-test-subj="toggle-case-state"]') + .simulate('change', { target: { value: false } }); + + expect(dispatchUpdateCaseProperty).toBeCalledWith({ + updateKey: 'state', + updateValue: 'closed', + }); + }); +}); 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 a92cf99097fce..5cd71c5855d34 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 @@ -60,13 +60,13 @@ const BackgroundWrapper = styled.div` `} `; -interface CasesProps { +export interface CaseProps { caseId: string; initialData: Case; isLoading: boolean; } -export const Cases = React.memo(({ caseId, initialData, isLoading }) => { +export const CaseComponent = React.memo(({ caseId, initialData, isLoading }) => { const [{ data }, dispatchUpdateCaseProperty] = useUpdateCase(caseId, initialData); const [isEditDescription, setIsEditDescription] = useState(false); const [isEditTags, setIsEditTags] = useState(false); @@ -162,14 +162,14 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) ]; const userActions = [ { - avatarName: data.created_by.username, + avatarName: data.createdBy.username, title: (

- {`${data.created_by.username}`} + {`${data.createdBy.username}`} {` ${i18n.ADDED_DESCRIPTION} `}{' '} - + {/* STEPH FIX come back and add label `on` */}

@@ -206,7 +206,7 @@ export const Cases = React.memo(({ caseId, initialData, isLoading })
) : ( - + ), }, ]; @@ -229,6 +229,7 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) href: getCaseUrl(), text: i18n.BACK_TO_ALL, }} + data-test-subj="case-view-title" titleNode={titleNode} title={title} > @@ -239,13 +240,21 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) {i18n.STATUS} - {data.state} + + {data.state} + {i18n.CASE_OPENED} - + @@ -255,6 +264,7 @@ export const Cases = React.memo(({ caseId, initialData, isLoading }) (({ caseId, initialData, isLoading }) - + { ); } - return ; + return ; }); CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 9fd1525003b0b..7d79e287b22e7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -48,8 +48,8 @@ export const Create = React.memo(() => { } }, [form]); - if (newCase && newCase.case_id) { - return ; + if (newCase && newCase.caseId) { + return ; } return ( 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 b80ee58f8abbf..33e0a9541c5b4 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 @@ -42,7 +42,7 @@ const renderUsers = (users: ElasticUser[]) => {

- {username} + {username}

@@ -50,7 +50,7 @@ const renderUsers = (users: ElasticUser[]) => {
window.alert('Email clicked')} + onClick={() => {}} // TO DO iconType="email" aria-label="email" /> diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index 42d333f4f893e..a087dca38de00 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -55,7 +55,7 @@ export const navTabs: SiemNavTab = { id: SiemPageName.case, name: i18n.CASE, href: getCaseUrl(), - disabled: true, + disabled: false, urlKey: 'case', }, }; diff --git a/x-pack/plugins/case/server/config.ts b/x-pack/plugins/case/server/config.ts index a7cb117198f9b..8ff9a793b17dc 100644 --- a/x-pack/plugins/case/server/config.ts +++ b/x-pack/plugins/case/server/config.ts @@ -7,9 +7,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), - indexPattern: schema.string({ defaultValue: '.case-test-2' }), - secret: schema.string({ defaultValue: 'Cool secret huh?' }), + enabled: schema.boolean({ defaultValue: true }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 37d087433a2ed..5ca640f0b25c3 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -34,6 +34,7 @@ export class CasePlugin { if (!config.enabled) { return; } + const service = new CaseService(this.log); this.log.debug( diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts index 23283d7f8a5be..25d5cafb4bb06 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -27,7 +27,8 @@ describe('UPDATE case', () => { id: 'mock-id-1', }, body: { - state: 'closed', + case: { state: 'closed' }, + version: 'WzAsMV0=', }, }); @@ -38,6 +39,42 @@ describe('UPDATE case', () => { expect(typeof response.payload.updated_at).toBe('string'); expect(response.payload.state).toEqual('closed'); }); + it(`Fails with 409 if version does not match`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'patch', + params: { + id: 'mock-id-1', + }, + body: { + case: { state: 'closed' }, + version: 'badv=', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(409); + }); + it(`Fails with 406 if updated field is unchanged`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'patch', + params: { + id: 'mock-id-1', + }, + body: { + case: { state: 'open' }, + version: 'WzAsMV0=', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(406); + }); it(`Returns an error if updateCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/{id}', diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts index 09075a32ac377..ba26a07dc2394 100644 --- a/x-pack/plugins/case/server/routes/api/get_all_cases.ts +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '.'; -import { formatAllCases, wrapError } from './utils'; +import { formatAllCases, sortToSnake, wrapError } from './utils'; import { SavedObjectsFindOptionsSchema } from './schema'; import { AllCases } from './types'; @@ -23,7 +23,10 @@ export function initGetAllCasesApi({ caseService, router }: RouteDeps) { const args = request.query ? { client: context.core.savedObjects.client, - options: request.query, + options: { + ...request.query, + sortField: sortToSnake(request.query.sortField ?? ''), + }, } : { client: context.core.savedObjects.client, diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts index 962dc474254f0..468abc8e7226f 100644 --- a/x-pack/plugins/case/server/routes/api/schema.ts +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -41,6 +41,11 @@ export const UpdatedCaseSchema = schema.object({ title: schema.maybe(schema.string()), }); +export const UpdateCaseArguments = schema.object({ + case: UpdatedCaseSchema, + version: schema.string(), +}); + export const SavedObjectsFindOptionsSchema = schema.object({ defaultSearchOperator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), fields: schema.maybe(schema.arrayOf(schema.string())), diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 2d1a88bcf1429..5f1c207bf9829 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -32,12 +32,14 @@ export interface CaseAttributes extends NewCaseType, SavedObjectAttributes { export type FlattenedCaseSavedObject = CaseAttributes & { case_id: string; + version: string; comments: FlattenedCommentSavedObject[]; }; export type FlattenedCasesSavedObject = Array< CaseAttributes & { case_id: string; + version: string; // TO DO it is partial because we need to add it the commentCount commentCount?: number; } @@ -52,6 +54,7 @@ export interface AllCases { export type FlattenedCommentSavedObject = CommentAttributes & { comment_id: string; + version: string; // TO DO We might want to add the case_id where this comment is related too }; @@ -69,3 +72,13 @@ export interface UpdatedCaseType { title?: UpdatedCaseTyped['title']; updated_at: string; } + +export enum SortFieldCase { + createdAt = 'created_at', + state = 'state', + updatedAt = 'updated_at', +} + +export type Writable = { + -readonly [K in keyof T]: T[K]; +}; diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts index 2a814c7259e4a..1c1a56dfe9b3a 100644 --- a/x-pack/plugins/case/server/routes/api/update_case.ts +++ b/x-pack/plugins/case/server/routes/api/update_case.ts @@ -5,9 +5,17 @@ */ import { schema } from '@kbn/config-schema'; +import { SavedObject } from 'kibana/server'; +import Boom from 'boom'; +import { difference } from 'lodash'; import { wrapError } from './utils'; import { RouteDeps } from '.'; -import { UpdatedCaseSchema } from './schema'; +import { UpdateCaseArguments } from './schema'; +import { CaseAttributes, UpdatedCaseTyped, Writable } from './types'; + +interface UpdateCase extends Writable { + [key: string]: any; +} export function initUpdateCaseApi({ caseService, router }: RouteDeps) { router.patch( @@ -17,23 +25,70 @@ export function initUpdateCaseApi({ caseService, router }: RouteDeps) { params: schema.object({ id: schema.string(), }), - body: UpdatedCaseSchema, + body: UpdateCaseArguments, }, }, async (context, request, response) => { + let theCase: SavedObject; try { - const updatedCase = await caseService.updateCase({ + theCase = await caseService.getCase({ client: context.core.savedObjects.client, caseId: request.params.id, - updatedAttributes: { - ...request.body, - updated_at: new Date().toISOString(), - }, }); - return response.ok({ body: updatedCase.attributes }); } catch (error) { return response.customError(wrapError(error)); } + + if (request.body.version !== theCase.version) { + return response.customError( + wrapError( + Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ) + ) + ); + } + const currentCase = theCase.attributes; + const updateCase: Partial = Object.entries(request.body.case).reduce( + (acc, [key, value]) => { + const currentValue = currentCase[key]; + if ( + Array.isArray(value) && + Array.isArray(currentValue) && + difference(value, currentValue).length !== 0 + ) { + return { + ...acc, + [key]: value, + }; + } else if (value !== currentCase[key]) { + return { + ...acc, + [key]: value, + }; + } + return acc; + }, + {} + ); + if (Object.keys(updateCase).length > 0) { + try { + const updatedCase = await caseService.updateCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + updatedAttributes: { + ...updateCase, + updated_at: new Date().toISOString(), + }, + }); + return response.ok({ body: { ...updatedCase.attributes, version: updatedCase.version } }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + return response.customError( + wrapError(Boom.notAcceptable('All update fields are identical to current version.')) + ); } ); } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 51944b04836ab..32de41e1c01c5 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -20,6 +20,7 @@ import { AllCases, NewCaseType, NewCommentType, + SortFieldCase, UserType, } from './types'; @@ -80,6 +81,7 @@ export const flattenCaseSavedObject = ( comments: Array> ): FlattenedCaseSavedObject => ({ case_id: savedObject.id, + version: savedObject.version ? savedObject.version : '0', comments: flattenCommentSavedObjects(comments), ...savedObject.attributes, }); @@ -107,5 +109,21 @@ export const flattenCommentSavedObject = ( savedObject: SavedObject ): FlattenedCommentSavedObject => ({ comment_id: savedObject.id, + version: savedObject.version ? savedObject.version : '0', ...savedObject.attributes, }); + +export const sortToSnake = (sortField: string): SortFieldCase => { + switch (sortField) { + case 'state': + return SortFieldCase.state; + case 'createdAt': + case 'created_at': + return SortFieldCase.createdAt; + case 'updatedAt': + case 'updated_at': + return SortFieldCase.updatedAt; + default: + return SortFieldCase.createdAt; + } +};