From 4b4bab7506058ea4bfae5b7ced07cfa057131978 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Tue, 5 May 2020 08:57:48 -0600 Subject: [PATCH] [SIEM] [Cases] External service selection per case (#64775) (#65258) --- x-pack/plugins/case/common/api/cases/case.ts | 1 + .../case/common/api/cases/user_actions.ts | 1 + .../api/__fixtures__/mock_saved_objects.ts | 33 +++ .../api/cases/comments/patch_comment.ts | 22 +- .../routes/api/cases/comments/post_comment.ts | 21 +- .../api/cases/configure/get_configure.ts | 2 +- .../routes/api/cases/find_cases.test.ts | 52 ++++- .../server/routes/api/cases/find_cases.ts | 10 +- .../server/routes/api/cases/get_case.test.ts | 83 +++++++- .../case/server/routes/api/cases/get_case.ts | 34 ++- .../case/server/routes/api/cases/helpers.ts | 15 +- .../routes/api/cases/patch_cases.test.ts | 54 +++++ .../server/routes/api/cases/patch_cases.ts | 36 +++- .../server/routes/api/cases/post_case.test.ts | 28 ++- .../case/server/routes/api/cases/post_case.ts | 19 +- .../case/server/routes/api/cases/push_case.ts | 17 +- .../case/server/routes/api/utils.test.ts | 186 +++++++++++++++-- .../plugins/case/server/routes/api/utils.ts | 37 ++-- .../case/server/saved_object_types/cases.ts | 3 + .../server/services/user_actions/helpers.ts | 1 + .../siem/cypress/screens/case_details.ts | 2 +- .../siem/public/containers/case/mock.ts | 16 +- .../siem/public/containers/case/types.ts | 1 + .../public/containers/case/use_get_case.tsx | 1 + .../case/use_get_case_user_actions.test.tsx | 193 ++++++++++++++++-- .../case/use_get_case_user_actions.tsx | 109 +++++++--- .../case/use_post_push_to_service.test.tsx | 85 +++++++- .../case/use_post_push_to_service.tsx | 42 +++- .../containers/case/use_update_case.tsx | 5 +- .../siem/public/containers/case/utils.ts | 8 + .../components/all_cases/columns.test.tsx | 10 +- .../case/components/all_cases/columns.tsx | 18 +- .../case/components/all_cases/translations.ts | 11 +- .../pages/case/components/case_view/index.tsx | 46 ++++- .../case/components/case_view/translations.ts | 15 ++ .../configure_cases/connectors_dropdown.tsx | 35 ++-- .../configure_cases/translations.ts | 12 +- .../confirm_delete_case/translations.ts | 4 +- .../components/connector_selector/form.tsx | 65 ++++++ .../components/edit_connector/index.test.tsx | 154 ++++++++++++++ .../case/components/edit_connector/index.tsx | 149 ++++++++++++++ .../case/components/edit_connector/schema.tsx | 12 ++ .../use_push_to_service/index.test.tsx | 103 +++------- .../components/use_push_to_service/index.tsx | 93 +++++---- .../use_push_to_service/translations.ts | 33 ++- .../user_action_tree/helpers.test.tsx | 64 ++++-- .../components/user_action_tree/helpers.tsx | 27 +-- .../user_action_tree/index.test.tsx | 34 ++- .../components/user_action_tree/index.tsx | 38 +++- .../user_action_tree/translations.ts | 22 +- .../user_action_tree/user_action_item.tsx | 7 +- .../siem/public/pages/case/translations.ts | 8 + .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 54 files changed, 1726 insertions(+), 359 deletions(-) create mode 100644 x-pack/plugins/siem/public/pages/case/components/connector_selector/form.tsx create mode 100644 x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx create mode 100644 x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx create mode 100644 x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index d1bcae549805e..586c2b0c2a259 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -16,6 +16,7 @@ export { ActionTypeExecutorResult } from '../../../../actions/server/types'; const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ + connector_id: rt.string, description: rt.string, status: StatusRt, tags: rt.array(rt.string), diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 2b70a698a5152..0bed0fd8fc57d 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -14,6 +14,7 @@ import { UserRT } from '../user'; const UserActionFieldRt = rt.array( rt.union([ rt.literal('comment'), + rt.literal('connector_id'), rt.literal('description'), rt.literal('pushed'), rt.literal('tags'), 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 75e793a80272f..135eeecdd491a 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 @@ -18,6 +18,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -46,6 +47,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -74,6 +76,7 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -106,6 +109,7 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -130,6 +134,35 @@ export const mockCases: Array> = [ }, ]; +export const mockCaseNoConnectorId: SavedObject> = { + type: 'cases', + id: 'mock-no-connector_id', + attributes: { + closed_at: null, + closed_by: null, + 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', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', +}; + export const mockCasesErrorTriggerData = [ { id: 'valid-id', 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 dd9b124ff1b79..90661a7d3897d 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 @@ -16,8 +16,14 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getConnectorId } from '../helpers'; -export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initPatchCommentApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.patch( { path: CASE_COMMENTS_URL, @@ -64,7 +70,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ + const [updatedComment, updatedCase, myCaseConfigure] = await Promise.all([ caseService.patchComment({ client, commentId: query.id, @@ -84,6 +90,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: }, version: myCase.version, }), + caseConfigureService.find({ client }), ]); const totalCommentsFindByCases = await caseService.getAllCaseComments({ @@ -95,7 +102,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: perPage: 1, }, }); - + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const [comments] = await Promise.all([ caseService.getAllCaseComments({ client, @@ -125,16 +132,17 @@ export function initPatchCommentApi({ caseService, router, userActionService }: return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase.attributes }, version: updatedCase.version ?? myCase.version, references: myCase.references, }, - comments.saved_objects - ) + comments: comments.saved_objects, + caseConfigureConnectorId, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index a296d9815f251..486f709b1e7ed 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -16,8 +16,14 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { escapeHatch, transformNewComment, wrapError, flattenCaseSavedObject } from '../../utils'; import { RouteDeps } from '../../types'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getConnectorId } from '../helpers'; -export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initPostCommentApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.post( { path: CASE_COMMENTS_URL, @@ -45,7 +51,7 @@ export function initPostCommentApi({ caseService, router, userActionService }: R const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); - const [newComment, updatedCase] = await Promise.all([ + const [newComment, updatedCase, myCaseConfigure] = await Promise.all([ caseService.postNewComment({ client, attributes: transformNewComment({ @@ -72,8 +78,10 @@ export function initPostCommentApi({ caseService, router, userActionService }: R }, version: myCase.version, }), + caseConfigureService.find({ client }), ]); + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const totalCommentsFindByCases = await caseService.getAllCaseComments({ client, caseId, @@ -112,16 +120,17 @@ export function initPostCommentApi({ caseService, router, userActionService }: R return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase.attributes }, version: updatedCase.version ?? myCase.version, references: myCase.references, }, - comments.saved_objects - ) + comments: comments.saved_objects, + caseConfigureConnectorId, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 03bec1fe72d39..5f83e8d6f94f5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -9,7 +9,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -export function initGetCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) { router.get( { path: CASE_CONFIGURE_URL, 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 7af1cee494457..9adb1eeb1bca0 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 @@ -15,8 +15,9 @@ import { } from '../__fixtures__'; import { initFindCasesApi } from './find_cases'; import { CASES_URL } from '../../../../common/constants'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -describe('GET all cases', () => { +describe('FIND all cases', () => { let routeHandler: RequestHandler; beforeAll(async () => { routeHandler = await createRoute(initFindCasesApi, 'get'); @@ -37,4 +38,53 @@ describe('GET all cases', () => { expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); }); + it(`has proper connector id on cases with configured id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[2].connector_id).toEqual('123'); + }); + it(`adds 'none' connector id to cases without when 3rd party unconfigured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[0].connector_id).toEqual('none'); + }); + it(`adds default connector id to cases without when 3rd party configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[0].connector_id).toEqual('123'); + }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 40fc0301b058a..cbe26ebe2f642 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -16,6 +16,7 @@ import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter(i => i !== '').join(` ${operator} `); @@ -39,7 +40,7 @@ const buildFilter = ( : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` : ''; -export function initFindCasesApi({ caseService, router }: RouteDeps) { +export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( { path: `${CASES_URL}/_find`, @@ -94,12 +95,12 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { filter: getStatusFilter('closed', myFilters), }, }; - const [cases, openCases, closesCases] = await Promise.all([ + const [cases, openCases, closesCases, myCaseConfigure] = await Promise.all([ caseService.findCases(args), caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), + caseConfigureService.find({ client }), ]); - const totalCommentsFindByCases = await Promise.all( cases.saved_objects.map(c => caseService.getAllCaseComments({ @@ -135,7 +136,8 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { cases, openCases.total ?? 0, closesCases.total ?? 0, - totalCommentsByCases + totalCommentsByCases, + getConnectorId(myCaseConfigure) ) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index a8c12d4734b53..6c0b5bdff418d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -19,6 +19,7 @@ import { import { flattenCaseSavedObject } from '../utils'; import { initGetCaseApi } from './get_case'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('GET case', () => { let routeHandler: RequestHandler; @@ -44,14 +45,11 @@ describe('GET case', () => { ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - + const savedObject = (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject< + CaseAttributes + >; expect(response.status).toEqual(200); - expect(response.payload).toEqual( - flattenCaseSavedObject( - (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject, - [] - ) - ); + expect(response.payload).toEqual(flattenCaseSavedObject({ savedObject })); expect(response.payload.comments).toEqual([]); }); it(`returns an error when thrown from getCase`, async () => { @@ -123,4 +121,75 @@ describe('GET case', () => { expect(response.status).toEqual(400); }); + it(`case w/o connector_id - returns the case with connector id when 3rd party unconfigured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-no-connector_id', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('none'); + }); + it(`case w/o connector_id - returns the case with connector id when 3rd party configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-no-connector_id', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); + }); + it(`case w/ connector_id - returns the case with connector id when case already has connectorId`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-id-3', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); + }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1e836d38c285c..57b472d3889cc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -10,8 +10,9 @@ import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { flattenCaseSavedObject, wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; -export function initGetCaseApi({ caseService, router }: RouteDeps) { +export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { router.get( { path: CASE_DETAILS_URL, @@ -29,13 +30,25 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); - const theCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); + const [theCase, myCaseConfigure] = await Promise.all([ + caseService.getCase({ + client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + ]); + + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); if (!includeComments) { - return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, [])) }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + caseConfigureConnectorId, + }) + ), + }); } const theComments = await caseService.getAllCaseComments({ @@ -48,7 +61,14 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }); return response.ok({ - body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, theComments.saved_objects)), + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + caseConfigureConnectorId, + }) + ), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 46c2209d79f7d..b02bc0b4e10a2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -6,7 +6,8 @@ import { get } from 'lodash'; -import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { CaseAttributes, CasePatchRequest, CasesConfigureAttributes } from '../../../../common/api'; interface CompareArrays { addedItems: string[]; @@ -75,8 +76,20 @@ export const getCaseToUpdate = ( ...acc, [key]: value, }; + } else if (currentValue == null && key === 'connector_id' && value !== currentValue) { + return { + ...acc, + [key]: value, + }; } return acc; }, { id: queryCase.id, version: queryCase.version } ); + +export const getConnectorId = ( + caseConfigure: SavedObjectsFindResponse +): string => + caseConfigure.saved_objects.length > 0 + ? caseConfigure.saved_objects[0].attributes.connector_id + : 'none'; 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 ac1e67cec52bd..b5100907f246a 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 @@ -15,6 +15,7 @@ import { mockCaseComments, } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -53,6 +54,7 @@ describe('PATCH cases', () => { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comments: [], + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', @@ -86,6 +88,7 @@ describe('PATCH cases', () => { const theContext = createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, }) ); @@ -96,6 +99,7 @@ describe('PATCH cases', () => { closed_at: null, closed_by: null, comments: [], + connector_id: '123', 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!', @@ -111,6 +115,56 @@ describe('PATCH cases', () => { }, ]); }); + it(`Patches a case without a connector_id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-no-connector_id', + status: 'closed', + version: 'WzAsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector_id).toEqual('none'); + }); + it(`Patches a case with a connector_id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-3', + status: 'closed', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector_id).toEqual('123'); + }); it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', 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 57f9fc20dbf34..6d2a5f943cea9 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 @@ -18,11 +18,16 @@ import { } from '../../../../common/api'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; -import { getCaseToUpdate } from './helpers'; +import { getCaseToUpdate, getConnectorId } from './helpers'; import { buildCaseUserActions } from '../../../services/user_actions/helpers'; import { CASES_URL } from '../../../../common/constants'; -export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initPatchCasesApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.patch( { path: CASES_URL, @@ -37,10 +42,16 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro excess(CasesPatchRequestRt).decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCases = await caseService.getCases({ - client, - caseIds: query.cases.map(q => q.id), - }); + + const [myCases, myCaseConfigure] = await Promise.all([ + caseService.getCases({ + client, + caseIds: query.cases.map(q => q.id), + }), + caseConfigureService.find({ client }), + ]); + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); @@ -114,11 +125,14 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro .map(myCase => { const updatedCase = updatedCases.saved_objects.find(c => c.id === myCase.id); return flattenCaseSavedObject({ - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + caseConfigureConnectorId, }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 0bbceb5214046..b545eb8b7fb08 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -15,6 +15,7 @@ import { } from '../__fixtures__'; import { initPostCaseApi } from './post_case'; import { CASES_URL } from '../../../../common/constants'; +import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; describe('POST cases', () => { let routeHandler: RequestHandler; @@ -25,7 +26,7 @@ describe('POST cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Posts a new case`, async () => { + it(`Posts a new case, no connector configured`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASES_URL, method: 'post', @@ -46,6 +47,29 @@ describe('POST cases', () => { expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); expect(response.payload.created_by.username).toEqual('awesome'); + expect(response.payload.connector_id).toEqual('none'); + }); + it(`Posts a new case, connector configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASES_URL, + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + tags: ['defacement'], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); }); it(`Error if you passing status for a new case`, async () => { @@ -106,6 +130,7 @@ describe('POST cases', () => { const theContext = createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, }) ); @@ -115,6 +140,7 @@ describe('POST cases', () => { closed_at: null, closed_by: null, comments: [], + connector_id: '123', created_at: '2019-11-25T21:54:48.952Z', created_by: { email: null, diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 059a8b1affd54..05574698edd44 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -15,8 +15,14 @@ import { CasePostRequestRt, throwErrors, excess, CaseResponseRt } from '../../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; -export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { +export function initPostCaseApi({ + caseService, + caseConfigureService, + router, + userActionService, +}: RouteDeps) { router.post( { path: CASES_URL, @@ -34,6 +40,8 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); + const myCaseConfigure = await caseConfigureService.find({ client }); + const connectorId = getConnectorId(myCaseConfigure); const newCase = await caseService.postNewCase({ client, attributes: transformNewCase({ @@ -42,6 +50,7 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout username, full_name, email, + connectorId, }), }); @@ -59,7 +68,13 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout ], }); - return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: newCase, + }) + ), + }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 94ebe24c3d2ae..c6638d292a197 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -16,6 +16,7 @@ import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; export function initPushCaseUserActionApi({ caseConfigureService, @@ -83,6 +84,11 @@ export function initPushCaseUserActionApi({ ...query, }; + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + // old case may not have new attribute connector_id, so we default to the configured system + const updateConnectorId = + myCase.attributes.connector_id == null ? { connector_id: caseConfigureConnectorId } : {}; + const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ client, @@ -98,6 +104,7 @@ export function initPushCaseUserActionApi({ external_service: externalService, updated_at: pushedDate, updated_by: { username, full_name, email }, + ...updateConnectorId, }, version: myCase.version, }), @@ -143,14 +150,14 @@ export function initPushCaseUserActionApi({ ]); return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase?.attributes }, references: myCase.references, }, - comments.saved_objects.map(origComment => { + comments: comments.saved_objects.map(origComment => { const updatedComment = updatedComments.saved_objects.find( c => c.id === origComment.id ); @@ -164,8 +171,8 @@ export function initPushCaseUserActionApi({ version: updatedComment?.version ?? origComment.version, references: origComment?.references ?? [], }; - }) - ) + }), + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index a22f4db30bf8d..81156b98bab83 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -18,13 +18,18 @@ import { } from './utils'; import { newCase } from './__mocks__/request_responses'; import { isBoom, boomify } from 'boom'; -import { mockCases, mockCaseComments } from './__fixtures__/mock_saved_objects'; +import { + mockCases, + mockCaseComments, + mockCaseNoConnectorId, +} from './__fixtures__/mock_saved_objects'; describe('Utils', () => { describe('transformNewCase', () => { it('transform correctly', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', full_name: 'Elastic', @@ -37,6 +42,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, external_service: null, @@ -49,6 +55,7 @@ describe('Utils', () => { it('transform correctly without optional fields', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', }; @@ -58,6 +65,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: undefined, full_name: undefined, username: undefined }, external_service: null, @@ -70,6 +78,7 @@ describe('Utils', () => { it('transform correctly with optional fields as null', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', email: null, full_name: null, @@ -82,6 +91,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: null, full_name: null, username: null }, external_service: null, @@ -204,7 +214,7 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const totalCommentsByCase = [ + const extraCaseData = [ { caseId: mockCases[0].id, totalComments: 2 }, { caseId: mockCases[1].id, totalComments: 2 }, { caseId: mockCases[2].id, totalComments: 2 }, @@ -215,13 +225,14 @@ describe('Utils', () => { { saved_objects: mockCases, total: mockCases.length, per_page: 10, page: 1 }, 2, 2, - totalCommentsByCase + extraCaseData, + '123' ); expect(res).toEqual({ page: 1, per_page: 10, total: mockCases.length, - cases: flattenCaseSavedObjects(mockCases, totalCommentsByCase), + cases: flattenCaseSavedObjects(mockCases, extraCaseData, '123'), count_open_cases: 2, count_closed_cases: 2, }); @@ -230,14 +241,15 @@ describe('Utils', () => { describe('flattenCaseSavedObjects', () => { it('flattens correctly', () => { - const totalCommentsByCase = [{ caseId: mockCases[0].id, totalComments: 2 }]; + const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -262,16 +274,94 @@ describe('Utils', () => { ]); }); - it('it handles total comments correctly', () => { - const totalCommentsByCase = [{ caseId: 'not-exist', totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + it('it handles total comments correctly when caseId is not in extraCaseData', () => { + const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, + connector_id: 'none', + 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', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + it('inserts missing connectorId', () => { + const extraCaseData = [ + { + caseId: mockCaseNoConnectorId.id, + totalComment: 0, + }, + ]; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData, '123'); + expect(res).toEqual([ + { + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: '123', + 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', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + it('inserts missing connectorId (none)', () => { + const extraCaseData = [ + { + caseId: mockCaseNoConnectorId.id, + totalComment: 0, + }, + ]; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); + expect(res).toEqual([ + { + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -300,7 +390,7 @@ describe('Utils', () => { describe('flattenCaseSavedObject', () => { it('flattens correctly', () => { const myCase = { ...mockCases[0] }; - const res = flattenCaseSavedObject(myCase, [], 2); + const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: myCase.version, @@ -313,7 +403,7 @@ describe('Utils', () => { it('flattens correctly without version', () => { const myCase = { ...mockCases[0] }; myCase.version = undefined; - const res = flattenCaseSavedObject(myCase, [], 2); + const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: '0', @@ -326,7 +416,7 @@ describe('Utils', () => { it('flattens correctly with comments', () => { const myCase = { ...mockCases[0] }; const comments = [{ ...mockCaseComments[0] }]; - const res = flattenCaseSavedObject(myCase, comments, 2); + const res = flattenCaseSavedObject({ savedObject: myCase, comments, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: myCase.version, @@ -335,6 +425,76 @@ describe('Utils', () => { ...myCase.attributes, }); }); + it('inserts missing connectorId', () => { + const extraCaseData = { + totalComment: 2, + caseConfigureConnectorId: '123', + }; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); + expect(res).toEqual({ + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: '123', + 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', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }); + }); + it('inserts missing connectorId (none)', () => { + const extraCaseData = { + totalComment: 2, + caseConfigureConnectorId: 'none', + }; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); + expect(res).toEqual({ + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: 'none', + 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', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }); + }); }); describe('transformComments', () => { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index a3df0fc93d2ac..b65205734d569 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,12 +26,14 @@ import { import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ + connectorId, createdDate, email, full_name, newCase, username, }: { + connectorId: string; createdDate: string; email?: string | null; full_name?: string | null; @@ -41,6 +43,7 @@ export const transformNewCase = ({ ...newCase, closed_at: null, closed_by: null, + connector_id: connectorId, created_at: createdDate, created_by: { email, full_name, username }, external_service: null, @@ -86,40 +89,50 @@ export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] + totalCommentByCase: TotalCommentByCase[], + caseConfigureConnectorId: string = 'none' ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase, caseConfigureConnectorId), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( savedObjects: SavedObjectsFindResponse['saved_objects'], - totalCommentByCase: TotalCommentByCase[] + totalCommentByCase: TotalCommentByCase[], + caseConfigureConnectorId: string = 'none' ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { return [ ...acc, - flattenCaseSavedObject( + flattenCaseSavedObject({ savedObject, - [], - totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 - ), + totalComment: + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0, + caseConfigureConnectorId, + }), ]; }, []); -export const flattenCaseSavedObject = ( - savedObject: SavedObject, - comments: Array> = [], - totalComment: number = 0 -): CaseResponse => ({ +export const flattenCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = 0, + caseConfigureConnectorId = 'none', +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + caseConfigureConnectorId?: string; +}): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + connector_id: savedObject.attributes.connector_id ?? caseConfigureConnectorId, ...savedObject.attributes, }); 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 cc2b1e74b38c4..26ed6ab7cc0bc 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -49,6 +49,9 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + connector_id: { + type: 'keyword', + }, external_service: { properties: { pushed_at: { diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index e89700419b19d..af50b3b394325 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -119,6 +119,7 @@ export const buildCaseUserActionItem = ({ const userActionFieldsAllowed: UserActionField = [ 'comment', + 'connector_id', 'description', 'tags', 'title', diff --git a/x-pack/plugins/siem/cypress/screens/case_details.ts b/x-pack/plugins/siem/cypress/screens/case_details.ts index 3bd180b1d588f..dadc1bdff1933 100644 --- a/x-pack/plugins/siem/cypress/screens/case_details.ts +++ b/x-pack/plugins/siem/cypress/screens/case_details.ts @@ -10,7 +10,7 @@ export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; -export const CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN = '[data-test-subj="push-to-service-now"]'; +export const CASE_DETAILS_PUSH_AS_SERVICE_NOW_BTN = '[data-test-subj="push-to-external-service"]'; export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; diff --git a/x-pack/plugins/siem/public/containers/case/mock.ts b/x-pack/plugins/siem/public/containers/case/mock.ts index a3a8db2c40950..8c55e55693963 100644 --- a/x-pack/plugins/siem/public/containers/case/mock.ts +++ b/x-pack/plugins/siem/public/containers/case/mock.ts @@ -19,6 +19,7 @@ import { CasesFindResponse, } from '../../../../case/common/api/cases'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; +export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; const basicCommentId = 'basic-comment-id'; @@ -31,6 +32,11 @@ export const elasticUser = { email: 'leslie.knope@elastic.co', }; +export const serviceConnectorUser = { + fullName: 'Leslie Knope', + username: 'lknope', +}; + export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { @@ -52,6 +58,7 @@ export const basicCase: Case = { comments: [basicComment], createdAt: basicCreatedAt, createdBy: elasticUser, + connectorId: '123', description: 'Security banana Issue', externalService: null, status: 'open', @@ -87,8 +94,8 @@ export const casesStatus: CasesStatus = { countOpenCases: 20, }; -const basicPush = { - connectorId: 'connector_id', +export const basicPush = { + connectorId: '123', connectorName: 'connector name', externalId: 'external_id', externalTitle: 'external title', @@ -192,6 +199,7 @@ export const basicCaseSnake: CaseResponse = { closed_at: null, closed_by: null, comments: [basicCommentSnake], + connector_id: '123', created_at: basicCreatedAt, created_by: elasticUserSnake, external_service: null, @@ -205,13 +213,13 @@ export const casesStatusSnake: CasesStatusResponse = { }; export const pushSnake = { - connector_id: 'connector_id', + connector_id: '123', connector_name: 'connector name', external_id: 'external_id', external_title: 'external title', external_url: 'basicPush.com', }; -const basicPushSnake = { +export const basicPushSnake = { ...pushSnake, pushed_at: basicUpdatedAt, pushed_by: elasticUserSnake, diff --git a/x-pack/plugins/siem/public/containers/case/types.ts b/x-pack/plugins/siem/public/containers/case/types.ts index dde13dc38aca8..648276cbc3c41 100644 --- a/x-pack/plugins/siem/public/containers/case/types.ts +++ b/x-pack/plugins/siem/public/containers/case/types.ts @@ -43,6 +43,7 @@ export interface Case { closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; + connectorId: string; createdAt: string; createdBy: ElasticUser; description: string; diff --git a/x-pack/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/plugins/siem/public/containers/case/use_get_case.tsx index b2e3b6d0cacf6..06d4c38ddda49 100644 --- a/x-pack/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_get_case.tsx @@ -59,6 +59,7 @@ export const initialData: Case = { closedBy: null, createdAt: '', comments: [], + connectorId: 'none', createdBy: { username: '', }, diff --git a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx index cdd40b84f8724..0848d12c8d308 100644 --- a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.test.tsx @@ -6,11 +6,19 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { + getPushedInfo, initialData, useGetCaseUserActions, UseGetCaseUserActions, } from './use_get_case_user_actions'; -import { basicCaseId, caseUserActions, elasticUser } from './mock'; +import { + basicCase, + basicPush, + basicPushSnake, + caseUserActions, + elasticUser, + getUserAction, +} from './mock'; import * as api from './api'; jest.mock('./api'); @@ -25,7 +33,7 @@ describe('useGetCaseUserActions', () => { it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -40,23 +48,23 @@ describe('useGetCaseUserActions', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); await waitForNextUpdate(); - expect(spyOnPostCase).toBeCalledWith(basicCaseId, abortCtrl.signal); + expect(spyOnPostCase).toBeCalledWith(basicCase.id, abortCtrl.signal); }); }); - it('retuns proper state on getCaseUserActions', async () => { + it('returns proper state on getCaseUserActions', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); await waitForNextUpdate(); expect(result.current).toEqual({ ...initialData, @@ -73,10 +81,10 @@ describe('useGetCaseUserActions', () => { it('set isLoading to true when posting case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); expect(result.current.isLoading).toBe(true); }); @@ -90,10 +98,10 @@ describe('useGetCaseUserActions', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCaseId) + useGetCaseUserActions(basicCase.id, basicCase.connectorId) ); await waitForNextUpdate(); - result.current.fetchCaseUserActions(basicCaseId); + result.current.fetchCaseUserActions(basicCase.id); expect(result.current).toEqual({ ...initialData, @@ -103,4 +111,165 @@ describe('useGetCaseUserActions', () => { }); }); }); + describe('getPushedInfo', () => { + it('Correctly marks first/last index - hasDataToPush: false', () => { + const userActions = [...caseUserActions, getUserAction(['pushed'], 'push-to-service')]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: false, + }, + }, + }); + }); + + it('Correctly marks first/last index - hasDataToPush: true', () => { + const userActions = [ + ...caseUserActions, + getUserAction(['pushed'], 'push-to-service'), + getUserAction(['comment'], 'create'), + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: true, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: true, + }, + }, + }); + }); + + it('Does not count connector_id update as a reason to push', () => { + const userActions = [ + ...caseUserActions, + getUserAction(['pushed'], 'push-to-service'), + getUserAction(['connector_id'], 'update'), + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: false, + }, + }, + }); + }); + it('Correctly handles multiple push actions', () => { + const userActions = [ + ...caseUserActions, + getUserAction(['pushed'], 'push-to-service'), + getUserAction(['comment'], 'create'), + getUserAction(['pushed'], 'push-to-service'), + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 5, + hasDataToPush: false, + }, + }, + }); + }); + + it('Multiple connector tracking - hasDataToPush: true', () => { + const pushAction123 = getUserAction(['pushed'], 'push-to-service'); + const push456 = { + ...basicPushSnake, + connector_id: '456', + connector_name: 'other connector name', + external_id: 'other_external_id', + }; + const pushAction456 = { + ...getUserAction(['pushed'], 'push-to-service'), + newValue: JSON.stringify(push456), + }; + + const userActions = [ + ...caseUserActions, + pushAction123, + getUserAction(['comment'], 'create'), + pushAction456, + ]; + const result = getPushedInfo(userActions, '123'); + expect(result).toEqual({ + hasDataToPush: true, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: true, + }, + '456': { + ...basicPush, + connectorId: '456', + connectorName: 'other connector name', + externalId: 'other_external_id', + firstPushIndex: 5, + lastPushIndex: 5, + hasDataToPush: false, + }, + }, + }); + }); + + it('Multiple connector tracking - hasDataToPush: false', () => { + const pushAction123 = getUserAction(['pushed'], 'push-to-service'); + const push456 = { + ...basicPushSnake, + connector_id: '456', + connector_name: 'other connector name', + external_id: 'other_external_id', + }; + const pushAction456 = { + ...getUserAction(['pushed'], 'push-to-service'), + newValue: JSON.stringify(push456), + }; + + const userActions = [ + ...caseUserActions, + pushAction123, + getUserAction(['comment'], 'create'), + pushAction456, + ]; + const result = getPushedInfo(userActions, '456'); + expect(result).toEqual({ + hasDataToPush: false, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 3, + lastPushIndex: 3, + hasDataToPush: true, + }, + '456': { + ...basicPush, + connectorId: '456', + connectorName: 'other connector name', + externalId: 'other_external_id', + firstPushIndex: 5, + lastPushIndex: 5, + hasDataToPush: false, + }, + }, + }); + }); + }); }); diff --git a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx index 6d9874a655e97..a2290f946be9b 100644 --- a/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -10,25 +10,35 @@ import { useCallback, useEffect, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { getCaseUserActions } from './api'; import * as i18n from './translations'; -import { CaseUserActions, ElasticUser } from './types'; +import { CaseExternalService, CaseUserActions, ElasticUser } from './types'; +import { convertToCamelCase, parseString } from './utils'; +import { CaseFullExternalService } from '../../../../case/common/api/cases'; + +interface CaseService extends CaseExternalService { + firstPushIndex: number; + lastPushIndex: number; + hasDataToPush: boolean; +} + +export interface CaseServices { + [key: string]: CaseService; +} interface CaseUserActionsState { + caseServices: CaseServices; caseUserActions: CaseUserActions[]; - firstIndexPushToService: number; hasDataToPush: boolean; - participants: ElasticUser[]; - isLoading: boolean; isError: boolean; - lastIndexPushToService: number; + isLoading: boolean; + participants: ElasticUser[]; } export const initialData: CaseUserActionsState = { + caseServices: {}, caseUserActions: [], - firstIndexPushToService: -1, - lastIndexPushToService: -1, hasDataToPush: false, - isLoading: true, isError: false, + isLoading: true, participants: [], }; @@ -36,26 +46,72 @@ export interface UseGetCaseUserActions extends CaseUserActionsState { fetchCaseUserActions: (caseId: string) => void; } -const getPushedInfo = ( - caseUserActions: CaseUserActions[] -): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { - const firstIndexPushToService = caseUserActions.findIndex( - cua => cua.action === 'push-to-service' - ); - const lastIndexPushToService = caseUserActions - .map(cua => cua.action) - .lastIndexOf('push-to-service'); +const getExternalService = (value: string): CaseExternalService | null => + convertToCamelCase(parseString(`${value}`)); + +export const getPushedInfo = ( + caseUserActions: CaseUserActions[], + caseConnectorId: string +): { + caseServices: CaseServices; + hasDataToPush: boolean; +} => { + const hasDataToPushForConnector = (connectorId: string) => { + const userActionsForPushLessServiceUpdates = caseUserActions.filter( + mua => + (mua.action !== 'push-to-service' && + !(mua.action === 'update' && mua.actionField[0] === 'connector_id')) || + (mua.action === 'push-to-service' && + connectorId === getExternalService(`${mua.newValue}`)?.connectorId) + ); + return ( + userActionsForPushLessServiceUpdates[userActionsForPushLessServiceUpdates.length - 1] + .action !== 'push-to-service' + ); + }; + + const caseServices = caseUserActions.reduce((acc, cua, i) => { + if (cua.action !== 'push-to-service') { + return acc; + } + const externalService = getExternalService(`${cua.newValue}`); + if (externalService === null) { + return acc; + } + + return { + ...acc, + ...(acc[externalService.connectorId] != null + ? { + [externalService.connectorId]: { + ...acc[externalService.connectorId], + ...externalService, + lastPushIndex: i, + }, + } + : { + [externalService.connectorId]: { + ...externalService, + firstPushIndex: i, + lastPushIndex: i, + hasDataToPush: hasDataToPushForConnector(externalService.connectorId), + }, + }), + }; + }, {}); const hasDataToPush = - lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + caseServices[caseConnectorId] != null ? caseServices[caseConnectorId].hasDataToPush : true; return { - firstIndexPushToService, - lastIndexPushToService, hasDataToPush, + caseServices, }; }; -export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { +export const useGetCaseUserActions = ( + caseId: string, + caseConnectorId: string +): UseGetCaseUserActions => { const [caseUserActionsState, setCaseUserActionsState] = useState( initialData ); @@ -84,7 +140,7 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => const caseUserActions = !isEmpty(response) ? response.slice(1) : []; setCaseUserActionsState({ caseUserActions, - ...getPushedInfo(caseUserActions), + ...getPushedInfo(caseUserActions, caseConnectorId), isLoading: false, isError: false, participants, @@ -98,12 +154,11 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => dispatchToaster, }); setCaseUserActionsState({ + caseServices: {}, caseUserActions: [], - firstIndexPushToService: -1, - lastIndexPushToService: -1, hasDataToPush: false, - isLoading: false, isError: true, + isLoading: false, participants: [], }); } @@ -115,13 +170,13 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => abortCtrl.abort(); }; }, - [caseUserActionsState] + [caseUserActionsState, caseConnectorId] ); useEffect(() => { if (!isEmpty(caseId)) { fetchCaseUserActions(caseId); } - }, [caseId]); + }, [caseId, caseConnectorId]); return { ...caseUserActionsState, fetchCaseUserActions }; }; diff --git a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx index b9698c3e864e3..72609e15d1ec4 100644 --- a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.test.tsx @@ -10,7 +10,14 @@ import { usePostPushToService, UsePostPushToService, } from './use_post_push_to_service'; -import { basicCase, pushedCase, serviceConnector } from './mock'; +import { + basicCase, + basicComment, + basicPush, + pushedCase, + serviceConnector, + serviceConnectorUser, +} from './mock'; import * as api from './api'; jest.mock('./api'); @@ -20,10 +27,54 @@ describe('usePostPushToService', () => { const updateCase = jest.fn(); const samplePush = { caseId: pushedCase.id, - connectorName: 'sample', - connectorId: '22', + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 1, + lastPushIndex: 1, + hasDataToPush: false, + }, + }, + connectorName: 'connector name', + connectorId: '123', updateCase, }; + const sampleServiceRequestData = { + caseId: pushedCase.id, + createdAt: pushedCase.createdAt, + createdBy: serviceConnectorUser, + comments: [ + { + commentId: basicComment.id, + comment: basicComment.comment, + createdAt: basicComment.createdAt, + createdBy: serviceConnectorUser, + updatedAt: null, + updatedBy: null, + }, + ], + externalId: basicPush.externalId, + description: pushedCase.description, + title: pushedCase.title, + updatedAt: pushedCase.updatedAt, + updatedBy: serviceConnectorUser, + }; + const sampleCaseServices = { + '123': { + ...basicPush, + firstPushIndex: 1, + lastPushIndex: 1, + hasDataToPush: true, + }, + '456': { + ...basicPush, + connectorId: '456', + externalId: 'other_external_id', + firstPushIndex: 4, + lastPushIndex: 6, + hasDataToPush: false, + }, + }; it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -76,7 +127,7 @@ describe('usePostPushToService', () => { await waitForNextUpdate(); expect(spyOnPushToService).toBeCalledWith( samplePush.connectorId, - formatServiceRequestData(basicCase), + formatServiceRequestData(basicCase, 'none', {}), abortCtrl.signal ); }); @@ -111,6 +162,32 @@ describe('usePostPushToService', () => { }); }); + it('formatServiceRequestData - current connector', () => { + const caseServices = sampleCaseServices; + const result = formatServiceRequestData(pushedCase, '123', caseServices); + expect(result).toEqual(sampleServiceRequestData); + }); + + it('formatServiceRequestData - connector with history', () => { + const caseServices = sampleCaseServices; + const result = formatServiceRequestData(pushedCase, '456', caseServices); + expect(result).toEqual({ + ...sampleServiceRequestData, + externalId: 'other_external_id', + }); + }); + + it('formatServiceRequestData - new connector', () => { + const caseServices = { + '123': sampleCaseServices['123'], + }; + const result = formatServiceRequestData(pushedCase, '456', caseServices); + expect(result).toEqual({ + ...sampleServiceRequestData, + externalId: null, + }); + }); + it('unhappy path', async () => { const spyOnPushToService = jest.spyOn(api, 'pushToService'); spyOnPushToService.mockImplementation(() => { diff --git a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx index c9d1b963f411a..3d0836cdc8adf 100644 --- a/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -15,6 +15,7 @@ import { errorToToaster, useStateToaster, displaySuccessToast } from '../../comp import { getCase, pushToService, pushCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; +import { CaseServices } from './use_get_case_user_actions'; interface PushToServiceState { serviceData: ServiceConnectorCaseResponse | null; @@ -65,11 +66,18 @@ interface PushToServiceRequest { caseId: string; connectorId: string; connectorName: string; + caseServices: CaseServices; updateCase: (newCase: Case) => void; } export interface UsePostPushToService extends PushToServiceState { - postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; + postPushToService: ({ + caseId, + caseServices, + connectorId, + connectorName, + updateCase, + }: PushToServiceRequest) => void; } export const usePostPushToService = (): UsePostPushToService => { @@ -82,7 +90,13 @@ export const usePostPushToService = (): UsePostPushToService => { const [, dispatchToaster] = useStateToaster(); const postPushToService = useCallback( - async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + async ({ + caseId, + caseServices, + connectorId, + connectorName, + updateCase, + }: PushToServiceRequest) => { let cancel = false; const abortCtrl = new AbortController(); try { @@ -90,7 +104,7 @@ export const usePostPushToService = (): UsePostPushToService => { const casePushData = await getCase(caseId, true, abortCtrl.signal); const responseService = await pushToService( connectorId, - formatServiceRequestData(casePushData), + formatServiceRequestData(casePushData, connectorId, caseServices), abortCtrl.signal ); const responseCase = await pushCase( @@ -131,7 +145,11 @@ export const usePostPushToService = (): UsePostPushToService => { return { ...state, postPushToService }; }; -export const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { +export const formatServiceRequestData = ( + myCase: Case, + connectorId: string, + caseServices: CaseServices +): ServiceConnectorCaseParams => { const { id: caseId, createdAt, @@ -143,6 +161,20 @@ export const formatServiceRequestData = (myCase: Case): ServiceConnectorCasePara updatedAt, updatedBy, } = myCase; + let actualExternalService = externalService; + if ( + externalService != null && + externalService.connectorId !== connectorId && + caseServices[connectorId] + ) { + actualExternalService = caseServices[connectorId]; + } else if ( + externalService != null && + externalService.connectorId !== connectorId && + !caseServices[connectorId] + ) { + actualExternalService = null; + } return { caseId, createdAt, @@ -180,7 +212,7 @@ export const formatServiceRequestData = (myCase: Case): ServiceConnectorCasePara : null, })), description, - externalId: externalService?.externalId ?? null, + externalId: actualExternalService?.externalId ?? null, title, updatedAt, updatedBy: diff --git a/x-pack/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/plugins/siem/public/containers/case/use_update_case.tsx index 2f2fe18321246..af824674999b9 100644 --- a/x-pack/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/plugins/siem/public/containers/case/use_update_case.tsx @@ -12,7 +12,10 @@ import { patchCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; -export type UpdateKey = keyof Pick; +export type UpdateKey = keyof Pick< + CasePatchRequest, + 'connector_id' | 'description' | 'status' | 'tags' | 'title' +>; interface NewCaseState { isLoading: boolean; diff --git a/x-pack/plugins/siem/public/containers/case/utils.ts b/x-pack/plugins/siem/public/containers/case/utils.ts index aaa5ff4ab44c1..15e514d6ea8b3 100644 --- a/x-pack/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/plugins/siem/public/containers/case/utils.ts @@ -31,6 +31,14 @@ import { AllCases, Case } from './types'; export const getTypedPayload = (a: unknown): T => a as T; +export const parseString = (params: string) => { + try { + return JSON.parse(params); + } catch { + return null; + } +}; + export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => arrayOfSnakes.reduce((acc: unknown[], value) => { if (isArray(value)) { diff --git a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx index 31c795c05edd5..2a06fa6eb51ac 100644 --- a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.test.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { mount } from 'enzyme'; -import { ServiceNowColumn } from './columns'; +import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../../../containers/case/mock'; -describe('ServiceNowColumn ', () => { +describe('ExternalServiceColumn ', () => { it('Not pushed render', () => { const wrapper = mount( - + ); expect( wrapper @@ -25,7 +25,7 @@ describe('ServiceNowColumn ', () => { }); it('Up to date', () => { const wrapper = mount( - + ); expect( wrapper @@ -36,7 +36,7 @@ describe('ServiceNowColumn ', () => { }); it('Needs update', () => { const wrapper = mount( - + ); expect( wrapper diff --git a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 9c2a7fc07f2d3..9a0460009ffac 100644 --- a/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -150,10 +150,22 @@ export const getCasesColumns = ( }, }, { - name: i18n.SERVICENOW_INCIDENT, + name: i18n.EXTERNAL_INCIDENT, render: (theCase: Case) => { if (theCase.id != null) { - return ; + return ; + } + return getEmptyTagValue(); + }, + }, + { + name: i18n.INCIDENT_MANAGEMENT_SYSTEM, + render: (theCase: Case) => { + if (theCase.externalService != null) { + return renderStringField( + `${theCase.externalService.connectorName}`, + `case-table-column-connector` + ); } return getEmptyTagValue(); }, @@ -168,7 +180,7 @@ interface Props { theCase: Case; } -export const ServiceNowColumn: React.FC = ({ theCase }) => { +export const ExternalServiceColumn: React.FC = ({ theCase }) => { const handleRenderDataToPush = useCallback(() => { const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null; const lastCasePush = diff --git a/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts index d3dcfa50ecfa5..d6e044abb8e89 100644 --- a/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -46,10 +46,17 @@ export const BULK_ACTIONS = i18n.translate('xpack.siem.case.caseTable.bulkAction defaultMessage: 'Bulk actions', }); -export const SERVICENOW_INCIDENT = i18n.translate('xpack.siem.case.caseTable.snIncident', { - defaultMessage: 'ServiceNow Incident', +export const EXTERNAL_INCIDENT = i18n.translate('xpack.siem.case.caseTable.snIncident', { + defaultMessage: 'External Incident', }); +export const INCIDENT_MANAGEMENT_SYSTEM = i18n.translate( + 'xpack.siem.case.caseTable.incidentSystem', + { + defaultMessage: 'Incident Management System', + } +); + export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.case.caseTable.searchPlaceholder', { defaultMessage: 'e.g. case name', }); diff --git a/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx index 01b9bc42f8e91..14039dc2cbc30 100644 --- a/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -35,6 +35,8 @@ import { navTabs } from '../../../home/home_navigations'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; import { usePushToService } from '../use_push_to_service'; +import { EditConnector } from '../edit_connector'; +import { useConnectors } from '../../../../containers/case/configure/use_connectors'; interface Props { caseId: string; @@ -67,17 +69,15 @@ export const CaseComponent = React.memo( const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; const search = useGetUrlSearch(navTabs.case); - const [initLoadingData, setInitLoadingData] = useState(true); const { caseUserActions, fetchCaseUserActions, - firstIndexPushToService, + caseServices, hasDataToPush, isLoading: isLoadingUserActions, - lastIndexPushToService, participants, - } = useGetCaseUserActions(caseId); + } = useGetCaseUserActions(caseId, caseData.connectorId); const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({ caseId, }); @@ -100,6 +100,18 @@ export const CaseComponent = React.memo( }); } break; + case 'connectorId': + const connectorId = getTypedPayload(updateValue); + if (connectorId.length > 0) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'connector_id', + updateValue: connectorId, + updateCase: handleUpdateNewCase, + version: caseData.version, + }); + } + break; case 'description': const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { @@ -147,14 +159,26 @@ export const CaseComponent = React.memo( [updateCase, fetchCaseUserActions] ); + const { loading: isLoadingConnectors, connectors } = useConnectors(); + const caseConnectorName = useMemo( + () => connectors.find(c => c.id === caseData.connectorId)?.name ?? 'none', + [connectors, caseData.connectorId] + ); const { pushButton, pushCallouts } = usePushToService({ + caseConnectorId: caseData.connectorId, + caseConnectorName, + caseServices, caseId: caseData.id, caseStatus: caseData.status, - isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + connectors, updateCase: handleUpdateCase, userCanCrud, }); + const onSubmitConnector = useCallback( + connectorId => onUpdateField('connectorId', connectorId), + [onUpdateField] + ); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [ onUpdateField, @@ -241,7 +265,7 @@ export const CaseComponent = React.memo( - {pushCallouts != null && pushCallouts} + {!initLoadingData && pushCallouts != null && pushCallouts} {initLoadingData && } @@ -249,12 +273,12 @@ export const CaseComponent = React.memo( <> ( onSubmit={onSubmitTags} isLoading={isLoading && updateKey === 'tags'} /> + diff --git a/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts index 70b8035db5c16..907527a5d8208 100644 --- a/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -26,6 +26,21 @@ export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabe defaultMessage: 'changed', }); +export const SELECTED_THIRD_PARTY = (thirdParty: string) => + i18n.translate('xpack.siem.case.caseView.actionLabel.selectedThirdParty', { + values: { + thirdParty, + }, + defaultMessage: 'selected { thirdParty } as incident management system', + }); + +export const REMOVED_THIRD_PARTY = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.removedThirdParty', + { + defaultMessage: 'removed external incident management system', + } +); + export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', { defaultMessage: 'edited', }); diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx index d5575f3bac4c8..bfd26d3cf8e00 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo } from 'react'; -import { EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; import { Connector } from '../../../../containers/case/configure/types'; @@ -24,15 +24,20 @@ const ICON_SIZE = 'm'; const EuiIconExtended = styled(EuiIcon)` margin-right: 13px; + margin-bottom: 0 !important; `; const noConnectorOption = { value: 'none', inputDisplay: ( - <> - - {i18n.NO_CONNECTOR} - + + + + + + {i18n.NO_CONNECTOR} + + ), 'data-test-subj': 'dropdown-connector-no-connector', }; @@ -52,13 +57,19 @@ const ConnectorsDropdownComponent: React.FC = ({ { value: connector.id, inputDisplay: ( - <> - - {connector.name} - + + + + + + + {connector.name} + + + ), 'data-test-subj': `dropdown-connector-${connector.id}`, }, diff --git a/x-pack/plugins/siem/public/pages/case/components/configure_cases/translations.ts b/x-pack/plugins/siem/public/pages/case/components/configure_cases/translations.ts index 49caeae1c3a34..2a09cb4f56b45 100644 --- a/x-pack/plugins/siem/public/pages/case/components/configure_cases/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/configure_cases/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( 'xpack.siem.case.configureCases.incidentManagementSystemTitle', { - defaultMessage: 'Connect to third-party incident management system', + defaultMessage: 'Connect to external incident management system', } ); @@ -17,7 +17,7 @@ export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( 'xpack.siem.case.configureCases.incidentManagementSystemDesc', { defaultMessage: - 'You may optionally connect SIEM cases to a third-party incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + 'You may optionally connect SIEM cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', } ); @@ -47,7 +47,7 @@ export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( 'xpack.siem.case.configureCases.caseClosureOptionsDesc', { defaultMessage: - 'Define how you wish SIEM cases to be closed. Automated case closures require an established connection to a third-party incident management system.', + 'Define how you wish SIEM cases to be closed. Automated case closures require an established connection to an external incident management system.', } ); @@ -68,14 +68,14 @@ export const CASE_CLOSURE_OPTIONS_MANUAL = i18n.translate( export const CASE_CLOSURE_OPTIONS_NEW_INCIDENT = i18n.translate( 'xpack.siem.case.configureCases.caseClosureOptionsNewIncident', { - defaultMessage: 'Automatically close SIEM cases when pushing new incident to third-party', + defaultMessage: 'Automatically close SIEM cases when pushing new incident to external system', } ); export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( 'xpack.siem.case.configureCases.caseClosureOptionsClosedIncident', { - defaultMessage: 'Automatically close SIEM cases when incident is closed in third-party', + defaultMessage: 'Automatically close SIEM cases when incident is closed in external system', } ); @@ -90,7 +90,7 @@ export const FIELD_MAPPING_DESC = i18n.translate( 'xpack.siem.case.configureCases.fieldMappingDesc', { defaultMessage: - 'Map SIEM case fields when pushing data to a third-party. Field mappings require an established connection to a third-party incident management system.', + 'Map SIEM case fields when pushing data to a third-party. Field mappings require an established connection to an external incident management system.', } ); diff --git a/x-pack/plugins/siem/public/pages/case/components/confirm_delete_case/translations.ts b/x-pack/plugins/siem/public/pages/case/components/confirm_delete_case/translations.ts index 06e940c60d0a1..a535286e7e8e5 100644 --- a/x-pack/plugins/siem/public/pages/case/components/confirm_delete_case/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/confirm_delete_case/translations.ts @@ -17,7 +17,7 @@ export const CONFIRM_QUESTION = i18n.translate( 'xpack.siem.case.confirmDeleteCase.confirmQuestion', { defaultMessage: - 'By deleting this case, all related case data will be permanently removed and you will no longer be able to push data to a third-party case management system. Are you sure you wish to proceed?', + 'By deleting this case, all related case data will be permanently removed and you will no longer be able to push data to an external incident management system. Are you sure you wish to proceed?', } ); export const DELETE_SELECTED_CASES = i18n.translate( @@ -31,6 +31,6 @@ export const CONFIRM_QUESTION_PLURAL = i18n.translate( 'xpack.siem.case.confirmDeleteCase.confirmQuestionPlural', { defaultMessage: - 'By deleting these cases, all related case data will be permanently removed and you will no longer be able to push data to a third-party case management system. Are you sure you wish to proceed?', + 'By deleting these cases, all related case data will be permanently removed and you will no longer be able to push data to an external incident management system. Are you sure you wish to proceed?', } ); diff --git a/x-pack/plugins/siem/public/pages/case/components/connector_selector/form.tsx b/x-pack/plugins/siem/public/pages/case/components/connector_selector/form.tsx new file mode 100644 index 0000000000000..5f0e498bb4056 --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/connector_selector/form.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 { EuiFormRow } from '@elastic/eui'; +import React, { useCallback, useEffect } from 'react'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; +import { Connector } from '../../../../../../case/common/api/cases'; + +interface ConnectorSelectorProps { + connectors: Connector[]; + dataTestSubj: string; + field: FieldHook; + idAria: string; + defaultValue?: string; + disabled: boolean; + isLoading: boolean; +} +export const ConnectorSelector = ({ + connectors, + dataTestSubj, + defaultValue, + field, + idAria, + disabled = false, + isLoading = false, +}: ConnectorSelectorProps) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + useEffect(() => { + field.setValue(defaultValue); + }, [defaultValue]); + + const handleContentChange = useCallback( + (newContent: string) => { + field.setValue(newContent); + }, + [field] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx new file mode 100644 index 0000000000000..29776360b72da --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 { EditConnector } from './index'; +import { getFormMock, useFormMock } from '../__mock__/form'; +import { TestProviders } from '../../../../mock'; +import { connectorsMock } from '../../../../containers/case/configure/mock'; +import { wait } from '../../../../lib/helpers'; +import { act } from 'react-dom/test-utils'; +jest.mock( + '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' +); +const onSubmit = jest.fn(); +const defaultProps = { + connectors: connectorsMock, + disabled: false, + isLoading: false, + onSubmit, + selectedConnector: 'none', +}; + +describe('EditConnector ', () => { + const sampleConnector = '123'; + const formHookMock = getFormMock({ connector: sampleConnector }); + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + useFormMock.mockImplementation(() => ({ form: formHookMock })); + }); + it('Renders no connector, and then edit', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="dropdown-connectors"]`) + .last() + .prop('disabled') + ).toBeTruthy(); + + expect( + wrapper + .find(`span[data-test-subj="dropdown-connector-no-connector"]`) + .last() + .exists() + ).toBeTruthy(); + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .simulate('click'); + + expect( + wrapper + .find(`[data-test-subj="edit-connectors-submit"]`) + .last() + .exists() + ).toBeTruthy(); + + expect( + wrapper + .find(`[data-test-subj="dropdown-connectors"]`) + .last() + .prop('disabled') + ).toBeFalsy(); + }); + it('Edit external service on submit', async () => { + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .simulate('click'); + expect( + wrapper + .find(`[data-test-subj="edit-connectors-submit"]`) + .last() + .exists() + ).toBeTruthy(); + await act(async () => { + wrapper + .find(`[data-test-subj="edit-connectors-submit"]`) + .last() + .simulate('click'); + await wait(); + expect(onSubmit).toBeCalledWith(sampleConnector); + }); + }); + it('Resets selector on cancel', async () => { + const props = { + ...defaultProps, + }; + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .simulate('click'); + await act(async () => { + wrapper + .find(`[data-test-subj="edit-connectors-cancel"]`) + .last() + .simulate('click'); + await wait(); + wrapper.update(); + expect(formHookMock.setFieldValue).toBeCalledWith( + 'connector', + defaultProps.selectedConnector + ); + }); + }); + it('Renders disabled button', () => { + const props = { ...defaultProps, disabled: true }; + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-test-subj="connector-edit-button"]`) + .last() + .prop('disabled') + ).toBeTruthy(); + }); + it('Renders loading spinner', () => { + const props = { ...defaultProps, isLoading: true }; + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-test-subj="connector-loading"]`) + .last() + .exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx new file mode 100644 index 0000000000000..83be8b5ad7e5a --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/edit_connector/index.tsx @@ -0,0 +1,149 @@ +/* + * 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, useState } from 'react'; +import { + EuiText, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiLoadingSpinner, +} from '@elastic/eui'; +import styled, { css } from 'styled-components'; +import * as i18n from '../../translations'; +import { Form, UseField, useForm } from '../../../../shared_imports'; +import { schema } from './schema'; +import { ConnectorSelector } from '../connector_selector/form'; +import { Connector } from '../../../../../../case/common/api/cases'; + +interface EditConnectorProps { + connectors: Connector[]; + disabled?: boolean; + isLoading: boolean; + onSubmit: (a: string[]) => void; + selectedConnector: string; +} + +const MyFlexGroup = styled(EuiFlexGroup)` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeM}; + p { + font-size: ${theme.eui.euiSizeM}; + } + `} +`; + +export const EditConnector = React.memo( + ({ + connectors, + disabled = false, + isLoading, + onSubmit, + selectedConnector, + }: EditConnectorProps) => { + const { form } = useForm({ + defaultValue: { connectors }, + options: { stripEmptyFields: false }, + schema, + }); + const [isEditConnector, setIsEditConnector] = useState(false); + const handleOnClick = useCallback(() => { + setIsEditConnector(true); + }, []); + + const onCancelConnector = useCallback(() => { + form.setFieldValue('connector', selectedConnector); + setIsEditConnector(false); + }, [form, selectedConnector]); + + const onSubmitConnector = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid && newData.connector) { + onSubmit(newData.connector); + setIsEditConnector(false); + } + }, [form, onSubmit]); + return ( + + + +

{i18n.CONNECTORS}

+
+ {isLoading && } + {!isLoading && ( + + + + )} +
+ + + + +
+ + + + + +
+
+ {isEditConnector && ( + + + + + {i18n.SAVE} + + + + + {i18n.CANCEL} + + + + + )} +
+
+
+ ); + } +); + +EditConnector.displayName = 'EditConnector'; diff --git a/x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx b/x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx new file mode 100644 index 0000000000000..4b9008839e695 --- /dev/null +++ b/x-pack/plugins/siem/public/pages/case/components/edit_connector/schema.tsx @@ -0,0 +1,12 @@ +/* + * 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 { FormSchema } from '../../../../shared_imports'; + +export const schema: FormSchema = { + connector: { + defaultValue: 'none', + }, +}; diff --git a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx index d428d9988ae39..b09c7d00140c3 100644 --- a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.test.tsx @@ -9,11 +9,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePushToService, ReturnUsePushToService, UsePushToService } from './'; import { TestProviders } from '../../../../mock'; import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; -import { ClosureType } from '../../../../../../case/common/api/cases'; +import { basicPush, actionLicenses } from '../../../../containers/case/mock'; import * as i18n from './translations'; import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; import { getKibanaConfigError, getLicenseError } from './helpers'; -import * as api from '../../../../containers/case/configure/api'; +import { connectorsMock } from '../../../../containers/case/configure/mock'; jest.mock('../../../../containers/case/use_get_action_license'); jest.mock('../../../../containers/case/use_post_push_to_service'); jest.mock('../../../../containers/case/configure/api'); @@ -26,28 +26,25 @@ describe('usePushToService', () => { isLoading: false, postPushToService, }; - const closureType: ClosureType = 'close-by-user'; - const mockConnector = { - connectorId: 'c00l', - connectorName: 'name', + const mockConnector = connectorsMock[0]; + const actionLicense = actionLicenses[0]; + const caseServices = { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + hasDataToPush: true, + }, }; - const mockCaseConfigure = { - ...mockConnector, - createdAt: 'string', - createdBy: {}, - closureType, - updatedAt: 'string', - updatedBy: {}, - version: 'string', - }; - const getConfigureMock = jest.spyOn(api, 'getCaseConfigure'); - const actionLicense = { - id: '.servicenow', - name: 'ServiceNow', - minimumLicenseRequired: 'platinum', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, + const defaultArgs = { + caseConnectorId: mockConnector.id, + caseConnectorName: mockConnector.name, + caseId, + caseServices, + caseStatus: 'open', + connectors: connectorsMock, + updateCase, + userCanCrud: true, }; beforeEach(() => { jest.resetAllMocks(); @@ -56,28 +53,24 @@ describe('usePushToService', () => { isLoading: false, actionLicense, })); - getConfigureMock.mockImplementation(() => Promise.resolve(mockCaseConfigure)); }); it('push case button posts the push with correct args', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => - usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, - }), + () => usePushToService(defaultArgs), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); - expect(getConfigureMock).toBeCalled(); result.current.pushButton.props.children.props.onClick(); - expect(postPushToService).toBeCalledWith({ ...mockConnector, caseId, updateCase }); + expect(postPushToService).toBeCalledWith({ + caseId, + caseServices, + connectorId: mockConnector.id, + connectorName: mockConnector.name, + updateCase, + }); expect(result.current.pushCallouts).toBeNull(); }); }); @@ -91,20 +84,12 @@ describe('usePushToService', () => { })); await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => - usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, - }), + () => usePushToService(defaultArgs), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(getLicenseError().title); @@ -120,48 +105,30 @@ describe('usePushToService', () => { })); await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => - usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, - }), + () => usePushToService(defaultArgs), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title); }); }); it('Displays message when user does not have a connector configured', async () => { - getConfigureMock.mockImplementation(() => - Promise.resolve({ - ...mockCaseConfigure, - connectorId: 'none', - }) - ); await act(async () => { const { result, waitForNextUpdate } = renderHook( () => usePushToService({ - caseId, - caseStatus: 'open', - isNew: false, - updateCase, - userCanCrud: true, + ...defaultArgs, + caseConnectorId: 'none', }), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); @@ -172,18 +139,14 @@ describe('usePushToService', () => { const { result, waitForNextUpdate } = renderHook( () => usePushToService({ - caseId, + ...defaultArgs, caseStatus: 'closed', - isNew: false, - updateCase, - userCanCrud: true, }), { wrapper: ({ children }) => {children}, } ); await waitForNextUpdate(); - await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE); diff --git a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx index 6109fd05096b9..7f3a951339ef1 100644 --- a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/index.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiLink, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; -import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; import { Case } from '../../../../containers/case/types'; import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; @@ -18,11 +17,16 @@ import { navTabs } from '../../../home/home_navigations'; import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; +import { Connector } from '../../../../../../case/common/api/cases'; +import { CaseServices } from '../../../../containers/case/use_get_case_user_actions'; export interface UsePushToService { caseId: string; caseStatus: string; - isNew: boolean; + caseConnectorId: string; + caseConnectorName: string; + caseServices: CaseServices; + connectors: Connector[]; updateCase: (newCase: Case) => void; userCanCrud: boolean; } @@ -33,9 +37,12 @@ export interface ReturnUsePushToService { } export const usePushToService = ({ + caseConnectorId, + caseConnectorName, caseId, + caseServices, caseStatus, - isNew, + connectors, updateCase, userCanCrud, }: UsePushToService): ReturnUsePushToService => { @@ -43,31 +50,30 @@ export const usePushToService = ({ const { isLoading, postPushToService } = usePostPushToService(); - const { connectorId, connectorName, loading: loadingCaseConfigure } = useCaseConfigure(); - const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); const handlePushToService = useCallback(() => { - if (connectorId != null) { + if (caseConnectorId != null && caseConnectorId !== 'none') { postPushToService({ caseId, - connectorId, - connectorName, + caseServices, + connectorId: caseConnectorId, + connectorName: caseConnectorName, updateCase, }); } - }, [caseId, connectorId, connectorName, postPushToService, updateCase]); + }, [caseId, caseServices, caseConnectorId, caseConnectorName, postPushToService, updateCase]); const errorsMsg = useMemo(() => { let errors: Array<{ title: string; description: JSX.Element }> = []; if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } - if (connectorId === 'none' && !loadingCaseConfigure && !loadingLicense) { + if (connectors.length === 0 && !loadingLicense) { errors = [ ...errors, { - title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE, description: ( + ), + }, + ]; } if (caseStatus === 'closed') { errors = [ @@ -102,40 +121,36 @@ export const usePushToService = ({ errors = [...errors, getKibanaConfigError()]; } return errors; - }, [actionLicense, caseStatus, connectorId, loadingCaseConfigure, loadingLicense, urlSearch]); + }, [actionLicense, caseStatus, connectors.length, caseConnectorId, loadingLicense, urlSearch]); - const pushToServiceButton = useMemo( - () => ( + const pushToServiceButton = useMemo(() => { + return ( 0 || - !userCanCrud - } + disabled={isLoading || loadingLicense || errorsMsg.length > 0 || !userCanCrud} isLoading={isLoading} > - {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + {caseServices[caseConnectorId] + ? i18n.UPDATE_THIRD(caseConnectorName) + : i18n.PUSH_THIRD(caseConnectorName)} - ), - [ - isNew, - handlePushToService, - isLoading, - loadingLicense, - loadingCaseConfigure, - errorsMsg, - userCanCrud, - ] - ); + ); + }, [ + caseConnectorId, + caseConnectorName, + connectors, + errorsMsg, + handlePushToService, + isLoading, + loadingLicense, + userCanCrud, + ]); - const objToReturn = useMemo( - () => ({ + const objToReturn = useMemo(() => { + return { pushButton: errorsMsg.length > 0 ? ( 0 ? ( ) : null, - }), - [errorsMsg, pushToServiceButton] - ); + }; + }, [errorsMsg, pushToServiceButton]); + return objToReturn; }; diff --git a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts index 14bdb0c69712c..2a36fcf8a6bc4 100644 --- a/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/use_push_to_service/translations.ts @@ -12,22 +12,41 @@ export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( defaultMessage: 'To send cases to external systems, you need to:', } ); +export const PUSH_THIRD = (thirdParty: string) => { + if (thirdParty === 'none') { + return i18n.translate('xpack.siem.case.caseView.pushThirdPartyIncident', { + defaultMessage: 'Push as third party incident', + }); + } + return i18n.translate('xpack.siem.case.caseView.pushNamedIncident', { + values: { thirdParty }, + defaultMessage: 'Push as { thirdParty } incident', + }); +}; -export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { - defaultMessage: 'Push as ServiceNow incident', -}); +export const UPDATE_THIRD = (thirdParty: string) => { + if (thirdParty === 'none') { + return i18n.translate('xpack.siem.case.caseView.updateThirdPartyIncident', { + defaultMessage: 'Update third party incident', + }); + } + return i18n.translate('xpack.siem.case.caseView.updateNamedIncident', { + values: { thirdParty }, + defaultMessage: 'Update { thirdParty } incident', + }); +}; -export const UPDATE_PUSH_SERVICENOW = i18n.translate( - 'xpack.siem.case.caseView.updatePushAsServicenowIncident', +export const PUSH_DISABLE_BY_NO_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoConfigTitle', { - defaultMessage: 'Update ServiceNow incident', + defaultMessage: 'Configure external connector', } ); export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', { - defaultMessage: 'Configure external connector', + defaultMessage: 'Select external connector', } ); diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx index e34981286bc81..6e7c2979f80bb 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.test.tsx @@ -5,19 +5,21 @@ */ import React from 'react'; -import { getUserAction } from '../../../../containers/case/mock'; +import { basicPush, getUserAction } from '../../../../containers/case/mock'; import { getLabelTitle } from './helpers'; import * as i18n from '../case_view/translations'; import { mount } from 'enzyme'; +import { connectorsMock } from '../../../../containers/case/configure/mock'; describe('User action tree helpers', () => { + const connectors = connectorsMock; it('label title generated for update tags', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'tags', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); const wrapper = mount(<>{result}); @@ -39,9 +41,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'title', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual( @@ -54,9 +56,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'description', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); @@ -65,9 +67,9 @@ describe('User action tree helpers', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'status', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); @@ -76,9 +78,9 @@ describe('User action tree helpers', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'status', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); @@ -87,9 +89,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'comment', - firstIndexPushToService: 0, - index: 0, + firstPush: false, }); expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); @@ -98,9 +100,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'pushed', - firstIndexPushToService: 0, - index: 0, + firstPush: true, }); const wrapper = mount(<>{result}); @@ -109,7 +111,7 @@ describe('User action tree helpers', () => { .find(`[data-test-subj="pushed-label"]`) .first() .text() - ).toEqual(i18n.PUSHED_NEW_INCIDENT); + ).toEqual(`${i18n.PUSHED_NEW_INCIDENT} ${basicPush.connectorName}`); expect( wrapper .find(`[data-test-subj="pushed-value"]`) @@ -121,9 +123,9 @@ describe('User action tree helpers', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ action, + connectors, field: 'pushed', - firstIndexPushToService: 0, - index: 1, + firstPush: false, }); const wrapper = mount(<>{result}); @@ -132,7 +134,7 @@ describe('User action tree helpers', () => { .find(`[data-test-subj="pushed-label"]`) .first() .text() - ).toEqual(i18n.UPDATE_INCIDENT); + ).toEqual(`${i18n.UPDATE_INCIDENT} ${basicPush.connectorName}`); expect( wrapper .find(`[data-test-subj="pushed-value"]`) @@ -140,4 +142,28 @@ describe('User action tree helpers', () => { .prop('href') ).toEqual(JSON.parse(action.newValue).external_url); }); + it('label title generated for update connector', () => { + const action = getUserAction(['connector_id'], 'update'); + const result: string | JSX.Element = getLabelTitle({ + action, + connectors, + field: 'tags', + firstPush: false, + }); + + const wrapper = mount(<>{result}); + expect( + wrapper + .find(`[data-test-subj="ua-tags-label"]`) + .first() + .text() + ).toEqual(` ${i18n.TAGS.toLowerCase()}`); + + expect( + wrapper + .find(`[data-test-subj="ua-tag"]`) + .first() + .text() + ).toEqual(action.newValue); + }); }); diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx index 96f87c9082945..285fa3c58c18a 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -7,24 +7,29 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; import React from 'react'; -import { CaseFullExternalService } from '../../../../../../case/common/api'; +import { CaseFullExternalService, Connector } from '../../../../../../case/common/api'; import { CaseUserActions } from '../../../../containers/case/types'; import * as i18n from '../case_view/translations'; interface LabelTitle { action: CaseUserActions; + connectors: Connector[]; field: string; - firstIndexPushToService: number; - index: number; + firstPush: boolean; } -export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { +export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTitle) => { if (field === 'tags') { return getTagsLabelTitle(action); } else if (field === 'title' && action.action === 'update') { return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ action.newValue }"`; + } else if (field === 'connector_id' && action.action === 'update') { + const newConnector = connectors.find(c => c.id === action.newValue); + return action.newValue != null && action.newValue !== 'none' && newConnector != null + ? i18n.SELECTED_THIRD_PARTY(newConnector.name) + : i18n.REMOVED_THIRD_PARTY; } else if (field === 'description' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; } else if (field === 'status' && action.action === 'update') { @@ -34,7 +39,7 @@ export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: } else if (field === 'comment' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { - return getPushedServiceLabelTitle(action, firstIndexPushToService, index); + return getPushedServiceLabelTitle(action, firstPush); } return ''; }; @@ -56,20 +61,18 @@ const getTagsLabelTitle = (action: CaseUserActions) => ( ); -const getPushedServiceLabelTitle = ( - action: CaseUserActions, - firstIndexPushToService: number, - index: number -) => { +const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; return ( - {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + {`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${ + pushedVal?.connector_name + }`} - {pushedVal?.connector_name} {pushedVal?.external_title} + {pushedVal?.external_title} diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx index ff402e8ea1c8b..736974545a1df 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { getFormMock, useFormMock } from '../__mock__/form'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; -import { basicCase, getUserAction } from '../../../../containers/case/mock'; +import { basicCase, basicPush, getUserAction } from '../../../../containers/case/mock'; import { UserActionTree } from './'; import { TestProviders } from '../../../../mock'; import { wait } from '../../../../lib/helpers'; @@ -20,16 +20,16 @@ const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); const updateCase = jest.fn(); const defaultProps = { - data: basicCase, + caseServices: {}, caseUserActions: [], - firstIndexPushToService: -1, + connectors: [], + data: basicCase, + fetchUserActions, isLoadingDescription: false, isLoadingUserActions: false, - lastIndexPushToService: -1, - userCanCrud: true, - fetchUserActions, onUpdateField, updateCase, + userCanCrud: true, }; const useUpdateCommentMock = useUpdateComment as jest.Mock; jest.mock('../../../../containers/case/use_update_comment'); @@ -76,13 +76,20 @@ describe('UserActionTree ', () => { }); it('Renders service now update line with top and bottom when push is required', () => { const ourActions = [ - getUserAction(['comment'], 'push-to-service'), + getUserAction(['pushed'], 'push-to-service'), getUserAction(['comment'], 'update'), ]; const props = { ...defaultProps, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + hasDataToPush: true, + }, + }, caseUserActions: ourActions, - lastIndexPushToService: 0, }; const wrapper = mount( @@ -95,11 +102,18 @@ describe('UserActionTree ', () => { expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); }); it('Renders service now update line with top only when push is up to date', () => { - const ourActions = [getUserAction(['comment'], 'push-to-service')]; + const ourActions = [getUserAction(['pushed'], 'push-to-service')]; const props = { ...defaultProps, caseUserActions: ourActions, - lastIndexPushToService: 0, + caseServices: { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + hasDataToPush: false, + }, + }, }; const wrapper = mount( diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index d1e8eb3f6306b..80d2c20631432 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -18,15 +18,18 @@ import { AddComment } from '../add_comment'; import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; +import { Connector } from '../../../../../../case/common/api/cases'; +import { CaseServices } from '../../../../containers/case/use_get_case_user_actions'; +import { parseString } from '../../../../containers/case/utils'; export interface UserActionTreeProps { - data: Case; + caseServices: CaseServices; caseUserActions: CaseUserActions[]; + connectors: Connector[]; + data: Case; fetchUserActions: () => void; - firstIndexPushToService: number; isLoadingDescription: boolean; isLoadingUserActions: boolean; - lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; updateCase: (newCase: Case) => void; userCanCrud: boolean; @@ -42,12 +45,12 @@ const NEW_ID = 'newComment'; export const UserActionTree = React.memo( ({ data: caseData, + caseServices, caseUserActions, + connectors, fetchUserActions, - firstIndexPushToService, isLoadingDescription, isLoadingUserActions, - lastIndexPushToService, onUpdateField, updateCase, userCanCrud, @@ -223,16 +226,30 @@ export const UserActionTree = React.memo( } if (action.actionField.length === 1) { const myField = action.actionField[0]; + const parsedValue = parseString(`${action.newValue}`); + const { firstPush, parsedConnectorId, parsedConnectorName } = + parsedValue != null + ? { + firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + parsedConnectorId: parsedValue.connector_id, + parsedConnectorName: parsedValue.connector_name, + } + : { + firstPush: false, + parsedConnectorId: 'none', + parsedConnectorName: 'none', + }; const labelTitle: string | JSX.Element = getLabelTitle({ action, field: myField, - firstIndexPushToService, - index, + firstPush, + connectors, }); return ( diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts index 066145f7762c9..e655329562d41 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -8,19 +8,17 @@ import { i18n } from '@kbn/i18n'; export * from '../case_view/translations'; -export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( - 'xpack.siem.case.caseView.alreadyPushedToService', - { - defaultMessage: 'Already pushed to Service Now incident', - } -); +export const ALREADY_PUSHED_TO_SERVICE = (externalService: string) => + i18n.translate('xpack.siem.case.caseView.alreadyPushedToExternalService', { + values: { externalService }, + defaultMessage: 'Already pushed to { externalService } incident', + }); -export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( - 'xpack.siem.case.caseView.requiredUpdateToService', - { - defaultMessage: 'Requires update to ServiceNow incident', - } -); +export const REQUIRED_UPDATE_TO_SERVICE = (externalService: string) => + i18n.translate('xpack.siem.case.caseView.requiredUpdateToExternalService', { + values: { externalService }, + defaultMessage: 'Requires update to { externalService } incident', + }); export const COPY_REFERENCE_LINK = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { defaultMessage: 'Copy reference link', diff --git a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 0acd0623f9413..eeb728aa7d1df 100644 --- a/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -20,6 +20,7 @@ import { UserActionTitle } from './user_action_title'; import * as i18n from './translations'; interface UserActionItemProps { + caseConnectorName?: string; createdAt: string; 'data-test-subj'?: string; disabled: boolean; @@ -85,6 +86,7 @@ export const UserActionItemContainer = styled(EuiFlexGroup)` `; const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + flex-grow: 0; ${({ theme, showoutline }) => showoutline === 'true' ? ` @@ -111,6 +113,7 @@ const PushedInfoContainer = styled.div` `; export const UserActionItem = ({ + caseConnectorName, createdAt, disabled, 'data-test-subj': dataTestSubj, @@ -177,14 +180,14 @@ export const UserActionItem = ({ - {i18n.ALREADY_PUSHED_TO_SERVICE} + {i18n.ALREADY_PUSHED_TO_SERVICE(`${caseConnectorName}`)} {showBottomFooter && ( - {i18n.REQUIRED_UPDATE_TO_SERVICE} + {i18n.REQUIRED_UPDATE_TO_SERVICE(`${caseConnectorName}`)} )} diff --git a/x-pack/plugins/siem/public/pages/case/translations.ts b/x-pack/plugins/siem/public/pages/case/translations.ts index 097b8220156e2..782ba9d9f32db 100644 --- a/x-pack/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/plugins/siem/public/pages/case/translations.ts @@ -195,3 +195,11 @@ export const GO_TO_DOCUMENTATION = i18n.translate( defaultMessage: 'View documentation', } ); + +export const CONNECTORS = i18n.translate('xpack.siem.case.caseView.connectors', { + defaultMessage: 'External incident management system', +}); + +export const EDIT_CONNECTOR = i18n.translate('xpack.siem.case.caseView.editConnector', { + defaultMessage: 'Change external incident management system', +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2d36854aed21b..2f7d5580d4be9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13152,7 +13152,6 @@ "xpack.siem.case.caseView.actionLabel.pushedNewIncident": "新しいインシデントとしてプッシュしました", "xpack.siem.case.caseView.actionLabel.removedField": "削除しました", "xpack.siem.case.caseView.actionLabel.updateIncident": "インシデントを更新しました", - "xpack.siem.case.caseView.alreadyPushedToService": "既に ServiceNow インシデントにプッシュされました", "xpack.siem.case.caseView.backLabel": "ケースに戻る", "xpack.siem.case.caseView.breadcrumb": "作成", "xpack.siem.case.caseView.cancel": "キャンセル", @@ -13190,7 +13189,6 @@ "xpack.siem.case.caseView.pageBadgeLabel": "ベータ", "xpack.siem.case.caseView.pageBadgeTooltip": "ケースワークフローはまだベータです。Kibana repo で問題や不具合を報告して製品の改善にご協力ください。", "xpack.siem.case.caseView.particpantsLabel": "参加者", - "xpack.siem.case.caseView.pushAsServicenowIncident": "ServiceNow インシデントとしてプッシュ", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedDescription": "終了したケースは外部システムに送信できません。外部システムでケースを開始または更新したい場合にはケースを再開します。", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle": "ケースを再開する", "xpack.siem.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml ファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabled Actiontypes 設定に .servicenow を追加します。詳細は {link} をご覧ください。", @@ -13202,11 +13200,9 @@ "xpack.siem.case.caseView.reopenCase": "ケースを再開", "xpack.siem.case.caseView.reopenedCase": "ケースを再開する", "xpack.siem.case.caseView.reporterLabel": "報告者", - "xpack.siem.case.caseView.requiredUpdateToService": "ServiceNow インシデントを更新する必要があります", "xpack.siem.case.caseView.statusLabel": "ステータス", "xpack.siem.case.caseView.tags": "タグ", "xpack.siem.case.caseView.to": "に", - "xpack.siem.case.caseView.updatePushAsServicenowIncident": "ServiceNow インシデントを更新", "xpack.siem.case.configureCases.addNewConnector": "新しいコネクターオプションを追加", "xpack.siem.case.configureCases.cancelButton": "キャンセル", "xpack.siem.case.configureCases.caseClosureOptionsClosedIncident": "新しいインシデントがサードパーティで閉じたときに SIEM ケースを自動的に閉じる", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 54a9e72e841ec..5455de4d35b32 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13159,7 +13159,6 @@ "xpack.siem.case.caseView.actionLabel.pushedNewIncident": "已推送为新事件", "xpack.siem.case.caseView.actionLabel.removedField": "移除了", "xpack.siem.case.caseView.actionLabel.updateIncident": "更新了事件", - "xpack.siem.case.caseView.alreadyPushedToService": "已推送到 Service Now 事件", "xpack.siem.case.caseView.backLabel": "返回到案例", "xpack.siem.case.caseView.breadcrumb": "创建", "xpack.siem.case.caseView.cancel": "取消", @@ -13197,7 +13196,6 @@ "xpack.siem.case.caseView.pageBadgeLabel": "公测版", "xpack.siem.case.caseView.pageBadgeTooltip": "案例工作流仍为公测版。请通过在 Kibana 存储库中报告问题或错误,帮助我们改进产品。", "xpack.siem.case.caseView.particpantsLabel": "参与者", - "xpack.siem.case.caseView.pushAsServicenowIncident": "作为 ServiceNow 事件推送", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedDescription": "关闭的案例无法发送到外部系统。如果希望在外部系统中打开或更新案例,请重新打开案例。", "xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle": "重新打开案例", "xpack.siem.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .servicenow 添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅 {link}。", @@ -13209,11 +13207,9 @@ "xpack.siem.case.caseView.reopenCase": "重新打开案例", "xpack.siem.case.caseView.reopenedCase": "重新打开的案例", "xpack.siem.case.caseView.reporterLabel": "报告者", - "xpack.siem.case.caseView.requiredUpdateToService": "需要更新 ServiceNow 事件", "xpack.siem.case.caseView.statusLabel": "状态", "xpack.siem.case.caseView.tags": "标记", "xpack.siem.case.caseView.to": "到", - "xpack.siem.case.caseView.updatePushAsServicenowIncident": "更新 ServiceNow 事件", "xpack.siem.case.configureCases.addNewConnector": "添加新连接器选项", "xpack.siem.case.configureCases.cancelButton": "取消", "xpack.siem.case.configureCases.caseClosureOptionsClosedIncident": "在第三方系统中关闭事件时自动关闭 SIEM 案例",