From 9fd5d9d8e118c151eca413b38afa264bc25e8ff9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 9 Feb 2021 14:28:58 +0200 Subject: [PATCH] [7.x] [Security Solution][Case] ServiceNow SIR Connector (#88655) (#90743) Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> --- .../servicenow/api.test.ts | 78 +++ .../builtin_action_types/servicenow/api.ts | 3 +- .../servicenow/index.test.ts | 120 +++++ .../builtin_action_types/servicenow/index.ts | 47 +- .../servicenow/translations.ts | 2 +- .../builtin_action_types/servicenow/types.ts | 1 + x-pack/plugins/case/common/api/cases/case.ts | 29 +- .../plugins/case/common/api/cases/comment.ts | 9 + .../case/common/api/cases/configure.ts | 4 - .../case/common/api/connectors/index.ts | 37 +- .../case/common/api/connectors/mappings.ts | 157 +----- .../{servicenow.ts => servicenow_itsm.ts} | 4 +- .../common/api/connectors/servicenow_sir.ts | 20 + x-pack/plugins/case/common/api/helpers.ts | 6 +- x-pack/plugins/case/common/constants.ts | 9 +- .../plugins/case/server/client/alerts/get.ts | 31 ++ .../case/server/client/alerts/types.ts | 19 + .../plugins/case/server/client/cases/get.ts | 45 ++ .../plugins/case/server/client/cases/mock.ts | 191 +++++++ .../plugins/case/server/client/cases/push.ts | 266 ++++++++++ .../plugins/case/server/client/cases/types.ts | 81 +++ .../configure => client/cases}/utils.test.ts | 467 ++++++++++++------ .../cases/configure => client/cases}/utils.ts | 238 ++++++--- .../case/server/client/configure/mock.ts | 4 +- .../case/server/client/configure/utils.ts | 10 +- .../plugins/case/server/client/index.test.ts | 86 ++-- x-pack/plugins/case/server/client/index.ts | 86 +--- x-pack/plugins/case/server/client/mocks.ts | 24 +- x-pack/plugins/case/server/client/types.ts | 51 +- .../case/server/client/user_actions/get.ts | 31 ++ .../case/server/connectors/case/index.ts | 3 +- .../plugins/case/server/connectors/index.ts | 48 +- .../jira/external_service_formatter.test.ts | 35 ++ .../jira/external_service_formatter.ts | 29 ++ .../external_service_formatter.test.ts | 26 + .../resilient/external_service_formatter.ts | 19 + .../connectors/servicenow/itsm_formatter.ts | 19 + .../servicenow/itsm_formmater.test.ts | 26 + .../servicenow/sir_formatter.test.ts | 164 ++++++ .../connectors/servicenow/sir_formatter.ts | 88 ++++ .../plugins/case/server/connectors/types.ts | 54 ++ x-pack/plugins/case/server/plugin.ts | 17 +- .../__fixtures__/create_mock_so_repository.ts | 14 + .../server/routes/api/__fixtures__/index.ts | 1 + .../api/__fixtures__/mock_actions_client.ts | 34 ++ .../api/__fixtures__/mock_saved_objects.ts | 47 +- .../routes/api/__fixtures__/route_contexts.ts | 25 +- .../routes/api/__mocks__/request_responses.ts | 29 +- .../api/cases/comments/delete_comment.test.ts | 8 +- .../api/cases/comments/get_comment.test.ts | 8 +- .../api/cases/comments/patch_comment.test.ts | 36 +- .../api/cases/comments/post_comment.test.ts | 40 +- .../api/cases/configure/get_configure.test.ts | 10 +- .../api/cases/configure/get_configure.ts | 4 +- .../cases/configure/get_connectors.test.ts | 16 +- .../api/cases/configure/get_connectors.ts | 13 +- .../server/routes/api/cases/configure/mock.ts | 76 --- .../cases/configure/patch_configure.test.ts | 14 +- .../api/cases/configure/patch_configure.ts | 2 +- .../cases/configure/post_configure.test.ts | 34 +- .../api/cases/configure/post_configure.ts | 4 +- .../configure/post_push_to_service.test.ts | 106 ---- .../cases/configure/post_push_to_service.ts | 81 --- .../routes/api/cases/delete_cases.test.ts | 16 +- .../routes/api/cases/find_cases.test.ts | 16 +- .../server/routes/api/cases/get_case.test.ts | 28 +- .../case/server/routes/api/cases/get_case.ts | 46 +- .../routes/api/cases/patch_cases.test.ts | 36 +- .../server/routes/api/cases/post_case.test.ts | 20 +- .../server/routes/api/cases/push_case.test.ts | 421 ++++++++++++++-- .../case/server/routes/api/cases/push_case.ts | 203 +------- .../api/cases/status/get_status.test.ts | 14 +- .../user_actions/get_all_user_actions.ts | 26 +- .../plugins/case/server/routes/api/index.ts | 6 +- .../plugins/case/server/routes/api/utils.ts | 18 +- .../case/server/services/alerts/index.ts | 44 ++ x-pack/plugins/case/server/services/mocks.ts | 1 + .../cases/connector_options.spec.ts | 7 + .../security_solution/cypress/objects/case.ts | 71 +++ .../cypress/screens/case_details.ts | 4 +- .../cypress/screens/edit_connector.ts | 2 +- .../cases/components/case_view/index.test.tsx | 13 +- .../cases/components/case_view/index.tsx | 2 - .../configure_cases/connectors.test.tsx | 4 +- .../components/configure_cases/index.test.tsx | 16 +- .../components/connector_selector/form.tsx | 18 +- .../{settings => connectors}/card.tsx | 6 +- .../case/{fields.tsx => alert_fields.tsx} | 0 .../cases/components/connectors/case/index.ts | 2 +- .../cases/components/connectors/config.ts | 34 +- .../connectors/connectors_registry.ts | 57 +++ .../{settings => connectors}/fields_form.tsx | 20 +- .../cases/components/connectors/index.ts | 46 ++ .../jira/__mocks__/api.ts | 0 .../{settings => connectors}/jira/api.test.ts | 0 .../{settings => connectors}/jira/api.ts | 0 .../jira/case_fields.test.tsx} | 2 +- .../jira/case_fields.tsx} | 19 +- .../{settings => connectors}/jira/index.ts | 6 +- .../jira/search_issues.tsx | 0 .../jira/translations.ts | 22 +- .../{settings => connectors}/jira/types.ts | 0 .../use_get_fields_by_issue_type.test.tsx | 0 .../jira/use_get_fields_by_issue_type.tsx | 0 .../jira/use_get_issue_types.test.tsx | 0 .../jira/use_get_issue_types.tsx | 0 .../jira/use_get_issues.test.tsx | 0 .../jira/use_get_issues.tsx | 0 .../jira/use_get_single_issue.test.tsx | 0 .../jira/use_get_single_issue.tsx | 0 .../cases/components/connectors/mock.ts | 109 ++++ .../resilient/__mocks__/api.ts | 21 +- .../{settings => connectors}/resilient/api.ts | 0 .../resilient/case_fields.test.tsx} | 2 +- .../resilient/case_fields.tsx} | 24 +- .../resilient/index.ts | 6 +- .../resilient/translations.ts | 10 +- .../resilient/types.ts | 0 .../resilient/use_get_incident_types.test.tsx | 0 .../resilient/use_get_incident_types.tsx | 0 .../resilient/use_get_severity.test.tsx | 0 .../resilient/use_get_severity.tsx | 0 .../connectors/servicenow/__mocks__/api.ts | 19 + .../connectors/servicenow/api.test.ts | 40 ++ .../components/connectors/servicenow/api.ts | 31 ++ .../components/connectors/servicenow/index.ts | 35 ++ .../servicenow_itsm_case_fields.test.tsx} | 62 ++- .../servicenow_itsm_case_fields.tsx | 164 ++++++ .../servicenow_sir_case_fields.test.tsx | 198 ++++++++ .../servicenow/servicenow_sir_case_fields.tsx | 293 +++++++++++ .../connectors/servicenow/translations.ts | 99 ++++ .../components/connectors/servicenow/types.ts | 18 + .../servicenow/use_get_choices.test.tsx | 144 ++++++ .../connectors/servicenow/use_get_choices.tsx | 99 ++++ .../cases/components/connectors/types.ts | 31 +- .../components/create/connector.test.tsx | 58 +-- .../cases/components/create/connector.tsx | 29 +- .../components/create/form_context.test.tsx | 62 +-- .../cases/components/create/form_context.tsx | 10 +- .../cases/components/create/index.test.tsx | 20 +- .../cases/components/edit_connector/index.tsx | 8 +- .../public/cases/components/settings/index.ts | 48 -- .../public/cases/components/settings/mock.ts | 21 - .../components/settings/servicenow/fields.tsx | 130 ----- .../components/settings/servicenow/index.ts | 25 - .../settings/servicenow/translations.ts | 49 -- .../components/settings/settings_registry.ts | 57 --- .../public/cases/components/settings/types.ts | 33 -- .../use_push_to_service/index.test.tsx | 38 +- .../components/use_push_to_service/index.tsx | 18 +- .../components/user_action_tree/index.tsx | 2 +- .../public/cases/containers/__mocks__/api.ts | 12 +- .../public/cases/containers/api.test.tsx | 106 ++-- .../public/cases/containers/api.ts | 39 +- .../public/cases/containers/mock.ts | 43 +- .../public/cases/containers/translations.ts | 7 - .../use_post_push_to_service.test.tsx | 304 +----------- .../containers/use_post_push_to_service.tsx | 219 ++------ .../public/cases/containers/utils.ts | 8 - .../translations/translations/ja-JP.json | 24 - .../translations/translations/zh-CN.json | 24 - .../builtin_action_types/jira/config.ts | 19 - .../builtin_action_types/jira/jira.tsx | 5 +- .../builtin_action_types/resilient/config.ts | 19 - .../resilient/resilient.tsx | 5 +- .../builtin_action_types/servicenow/config.ts | 31 -- .../servicenow/servicenow.tsx | 13 +- .../servicenow_itsm_params.test.tsx | 47 +- .../servicenow/translations.ts | 6 +- .../public/common/index.ts | 9 +- .../actions_simulators/server/plugin.ts | 3 + .../basic/tests/cases/push_case.ts | 167 +++++-- .../user_actions/get_all_user_actions.ts | 12 +- .../case_api_integration/common/lib/utils.ts | 4 +- 174 files changed, 4847 insertions(+), 2824 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts rename x-pack/plugins/case/common/api/connectors/{servicenow.ts => servicenow_itsm.ts} (76%) create mode 100644 x-pack/plugins/case/common/api/connectors/servicenow_sir.ts create mode 100644 x-pack/plugins/case/server/client/alerts/get.ts create mode 100644 x-pack/plugins/case/server/client/alerts/types.ts create mode 100644 x-pack/plugins/case/server/client/cases/get.ts create mode 100644 x-pack/plugins/case/server/client/cases/mock.ts create mode 100644 x-pack/plugins/case/server/client/cases/push.ts create mode 100644 x-pack/plugins/case/server/client/cases/types.ts rename x-pack/plugins/case/server/{routes/api/cases/configure => client/cases}/utils.test.ts (52%) rename x-pack/plugins/case/server/{routes/api/cases/configure => client/cases}/utils.ts (50%) create mode 100644 x-pack/plugins/case/server/client/user_actions/get.ts create mode 100644 x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts create mode 100644 x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts create mode 100644 x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.ts create mode 100644 x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts create mode 100644 x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts create mode 100644 x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts create mode 100644 x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts create mode 100644 x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts create mode 100644 x-pack/plugins/case/server/connectors/types.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.ts delete mode 100644 x-pack/plugins/case/server/routes/api/cases/configure/mock.ts delete mode 100644 x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts delete mode 100644 x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/card.tsx (89%) rename x-pack/plugins/security_solution/public/cases/components/connectors/case/{fields.tsx => alert_fields.tsx} (100%) create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/fields_form.tsx (64%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/__mocks__/api.ts (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/api.test.ts (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/api.ts (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings/jira/fields.test.tsx => connectors/jira/case_fields.test.tsx} (99%) rename x-pack/plugins/security_solution/public/cases/components/{settings/jira/fields.tsx => connectors/jira/case_fields.tsx} (91%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/index.ts (77%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/search_issues.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/translations.ts (60%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/types.ts (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_fields_by_issue_type.test.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_fields_by_issue_type.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_issue_types.test.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_issue_types.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_issues.test.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_issues.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_single_issue.test.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/jira/use_get_single_issue.tsx (100%) create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/__mocks__/api.ts (70%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/api.ts (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings/resilient/fields.test.tsx => connectors/resilient/case_fields.test.tsx} (99%) rename x-pack/plugins/security_solution/public/cases/components/{settings/resilient/fields.tsx => connectors/resilient/case_fields.tsx} (90%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/index.ts (77%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/translations.ts (67%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/types.ts (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/use_get_incident_types.test.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/use_get_incident_types.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/use_get_severity.test.tsx (100%) rename x-pack/plugins/security_solution/public/cases/components/{settings => connectors}/resilient/use_get_severity.tsx (100%) create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts rename x-pack/plugins/security_solution/public/cases/components/{settings/servicenow/fields.test.tsx => connectors/servicenow/servicenow_itsm_case_fields.test.tsx} (52%) create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/index.ts delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/mock.ts delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts delete mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/types.ts delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 7ad6ec337bca1b..662b1ce46a07b6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -16,6 +16,7 @@ describe('api', () => { beforeEach(() => { externalService = externalServiceMock.create(); + jest.clearAllMocks(); }); describe('create incident', () => { @@ -26,6 +27,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -57,6 +59,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -77,6 +80,7 @@ describe('api', () => { params, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.createIncident).toHaveBeenCalledWith({ @@ -99,6 +103,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.updateIncident).toHaveBeenCalledTimes(2); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { @@ -125,6 +130,41 @@ describe('api', () => { incidentId: 'incident-1', }); }); + + test('it post comments to different comment field key', async () => { + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ + externalService, + params, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + expect(externalService.updateIncident).toHaveBeenCalledTimes(2); + expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + work_notes: 'A comment', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-1', + }); + + expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + work_notes: 'Another comment', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-1', + }); + }); }); describe('update incident', () => { @@ -134,6 +174,7 @@ describe('api', () => { params: apiParams, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -161,6 +202,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -178,6 +220,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.updateIncident).toHaveBeenCalledWith({ @@ -200,6 +243,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.updateIncident).toHaveBeenCalledTimes(3); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { @@ -225,6 +269,40 @@ describe('api', () => { incidentId: 'incident-2', }); }); + + test('it post comments to different comment field key', async () => { + const params = { ...apiParams }; + await api.pushToService({ + externalService, + params, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + expect(externalService.updateIncident).toHaveBeenCalledTimes(3); + expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-3', + }); + + expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + work_notes: 'A comment', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-2', + }); + }); }); describe('getFields', () => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 3aa1e50dc2aeb4..4120c07c32303f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -25,6 +25,7 @@ const pushToServiceHandler = async ({ externalService, params, secrets, + commentFieldKey, }: PushToServiceApiHandlerArgs): Promise => { const { comments } = params; let res: PushToServiceResponse; @@ -53,7 +54,7 @@ const pushToServiceHandler = async ({ incidentId: res.id, incident: { ...incident, - comments: currentComment.comment, + [commentFieldKey]: currentComment.comment, }, }); res.comments = [ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts new file mode 100644 index 00000000000000..e7e2b2bc4118e2 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { actionsMock } from '../../mocks'; +import { createActionTypeRegistry } from '../index.test'; +import { + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse, +} from './types'; +import { + ServiceNowActionType, + ServiceNowITSMActionTypeId, + ServiceNowSIRActionTypeId, + ServiceNowActionTypeExecutorOptions, +} from '.'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + getChoices: jest.fn(), + getFields: jest.fn(), + getIncident: jest.fn(), + handshake: jest.fn(), + pushToService: jest.fn(), + }, +})); + +const services = actionsMock.createServices(); + +describe('ServiceNow', () => { + const config = { apiUrl: 'https://instance.com' }; + const secrets = { username: 'username', password: 'password' }; + const params = { + subAction: 'pushToService', + subActionParams: { + incident: { + short_description: 'An incident', + description: 'This is serious', + }, + }, + }; + + beforeEach(() => { + (api.pushToService as jest.Mock).mockResolvedValue({ id: 'some-id' }); + }); + + describe('ServiceNow ITSM', () => { + let actionType: ServiceNowActionType; + + beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse | {} + >(ServiceNowITSMActionTypeId); + }); + + describe('execute()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it pass the correct comment field key', async () => { + const actionId = 'some-action-id'; + const executorOptions = ({ + actionId, + config, + secrets, + params, + services, + } as unknown) as ServiceNowActionTypeExecutorOptions; + await actionType.executor(executorOptions); + expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe('comments'); + }); + }); + }); + + describe('ServiceNow SIR', () => { + let actionType: ServiceNowActionType; + + beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse | {} + >(ServiceNowSIRActionTypeId); + }); + + describe('execute()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it pass the correct comment field key', async () => { + const actionId = 'some-action-id'; + const executorOptions = ({ + actionId, + config, + secrets, + params, + services, + } as unknown) as ServiceNowActionTypeExecutorOptions; + await actionType.executor(executorOptions); + expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe( + 'work_notes' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index cf9cef3c776c71..f6be7c90820a25 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -47,15 +47,21 @@ const serviceNowSIRTable = 'sn_si_incident'; export const ServiceNowITSMActionTypeId = '.servicenow'; export const ServiceNowSIRActionTypeId = '.servicenow-sir'; -// action type definition -export function getServiceNowITSMActionType( - params: GetActionTypeParams -): ActionType< +export type ServiceNowActionType = ActionType< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse | {} -> { +>; + +export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams +>; + +// action type definition +export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowITSMActionTypeId, @@ -74,14 +80,7 @@ export function getServiceNowITSMActionType( }; } -export function getServiceNowSIRActionType( - params: GetActionTypeParams -): ActionType< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams, - PushToServiceResponse | {} -> { +export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowSIRActionTypeId, @@ -96,7 +95,12 @@ export function getServiceNowSIRActionType( }), params: ExecutorParamsSchemaSIR, }, - executor: curry(executor)({ logger, configurationUtilities, table: serviceNowSIRTable }), + executor: curry(executor)({ + logger, + configurationUtilities, + table: serviceNowSIRTable, + commentFieldKey: 'work_notes', + }), }; } @@ -107,12 +111,14 @@ async function executor( logger, configurationUtilities, table, - }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; table: string }, - execOptions: ActionTypeExecutorOptions< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams - > + commentFieldKey = 'comments', + }: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + table: string; + commentFieldKey?: string; + }, + execOptions: ServiceNowActionTypeExecutorOptions ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; @@ -147,6 +153,7 @@ async function executor( params: pushToServiceParams, secrets, logger, + commentFieldKey, }); logger.debug(`response push to service for incident id: ${data.id}`); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 2110e9425fe6ce..b46e118a7235f3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -16,7 +16,7 @@ export const SERVICENOW_ITSM = i18n.translate('xpack.actions.builtin.serviceNowI }); export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSIRTitle', { - defaultMessage: 'ServiceNow SIR', + defaultMessage: 'ServiceNow SecOps', }); export const ALLOWED_HOSTS_ERROR = (message: string) => diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 8de3f911106c05..1c0b2c9c62eeeb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -121,6 +121,7 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr params: PushToServiceApiParams; secrets: Record; logger: Logger; + commentFieldKey: string; } export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs { diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index cd13b10846f124..bebd261fb7b9b3 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -80,8 +80,6 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); -export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; - export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), status: CaseStatusRt, @@ -126,6 +124,31 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +export const CasePushRequestParamsRt = rt.type({ + case_id: rt.string, + connector_id: rt.string, +}); + +export const ExternalServiceResponseRt = rt.intersection([ + rt.type({ + title: rt.string, + id: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.intersection([ + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }), + rt.partial({ externalCommentId: rt.string }), + ]) + ), + }), +]); + export type CaseAttributes = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -133,8 +156,8 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; -export type CaseExternalServiceRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; +export type ExternalServiceResponse = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; export type ESCasePatchRequest = Omit & { diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 0670526e0df9cd..7c9b31f496e54d 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -45,6 +45,14 @@ export const CommentResponseRt = rt.intersection([ }), ]); +export const CommentResponseTypeAlertsRt = rt.intersection([ + AttributesTypeAlertsRt, + rt.type({ + id: rt.string, + version: rt.string, + }), +]); + export const AllCommentsResponseRT = rt.array(CommentResponseRt); export const CommentPatchRequestRt = rt.intersection([ @@ -84,6 +92,7 @@ export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; +export type CommentResponseAlertsType = rt.TypeOf; export type AllCommentsResponse = rt.TypeOf; export type CommentsResponse = rt.TypeOf; export type CommentPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index cb3a8b68082dc2..b5a89efde17678 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -7,13 +7,9 @@ import * as rt from 'io-ts'; -import { ActionResult, ActionType } from '../../../../actions/common'; import { UserRT } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; -export type ActionConnector = ActionResult; -export type ActionTypeConnector = ActionType; - // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index 5fead4c8bd9c5b..f9b7c8b12c2cd1 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -7,25 +7,34 @@ import * as rt from 'io-ts'; +import { ActionResult, ActionType } from '../../../../actions/common'; import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; -import { ServiceNowFieldsRT } from './servicenow'; +import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; +import { ServiceNowSIRFieldsRT } from './servicenow_sir'; export * from './jira'; -export * from './servicenow'; +export * from './servicenow_itsm'; +export * from './servicenow_sir'; export * from './resilient'; export * from './mappings'; +export type ActionConnector = ActionResult; +export type ActionTypeConnector = ActionType; + export const ConnectorFieldsRt = rt.union([ JiraFieldsRT, ResilientFieldsRT, - ServiceNowFieldsRT, + ServiceNowITSMFieldsRT, + ServiceNowSIRFieldsRT, rt.null, ]); + export enum ConnectorTypes { jira = '.jira', resilient = '.resilient', - servicenow = '.servicenow', + serviceNowITSM = '.servicenow', + serviceNowSIR = '.servicenow-sir', none = '.none', } @@ -39,9 +48,14 @@ const ConnectorResillientTypeFieldsRt = rt.type({ fields: rt.union([ResilientFieldsRT, rt.null]), }); -const ConnectorServiceNowTypeFieldsRt = rt.type({ - type: rt.literal(ConnectorTypes.servicenow), - fields: rt.union([ServiceNowFieldsRT, rt.null]), +const ConnectorServiceNowITSMTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.serviceNowITSM), + fields: rt.union([ServiceNowITSMFieldsRT, rt.null]), +}); + +const ConnectorServiceNowSIRTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.serviceNowSIR), + fields: rt.union([ServiceNowSIRFieldsRT, rt.null]), }); const ConnectorNoneTypeFieldsRt = rt.type({ @@ -52,7 +66,8 @@ const ConnectorNoneTypeFieldsRt = rt.type({ export const ConnectorTypeFieldsRt = rt.union([ ConnectorJiraTypeFieldsRt, ConnectorResillientTypeFieldsRt, - ConnectorServiceNowTypeFieldsRt, + ConnectorServiceNowITSMTypeFieldsRt, + ConnectorServiceNowSIRTypeFieldsRt, ConnectorNoneTypeFieldsRt, ]); @@ -66,6 +81,12 @@ export const CaseConnectorRt = rt.intersection([ export type CaseConnector = rt.TypeOf; export type ConnectorTypeFields = rt.TypeOf; +export type ConnectorJiraTypeFields = rt.TypeOf; +export type ConnectorResillientTypeFields = rt.TypeOf; +export type ConnectorServiceNowITSMTypeFields = rt.TypeOf< + typeof ConnectorServiceNowITSMTypeFieldsRt +>; +export type ConnectorServiceNowSIRTypeFields = rt.TypeOf; // we need to change these types back and forth for storing in ES (arrays overwrite, objects merge) export type ConnectorFields = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/mappings.ts b/x-pack/plugins/case/common/api/connectors/mappings.ts index 38e3434f0e7a82..3d2013af476880 100644 --- a/x-pack/plugins/case/common/api/connectors/mappings.ts +++ b/x-pack/plugins/case/common/api/connectors/mappings.ts @@ -5,42 +5,7 @@ * 2.0. */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ - import * as rt from 'io-ts'; -import { - PushToServiceApiParams as JiraPushToServiceApiParams, - Incident as JiraIncident, -} from '../../../../actions/server/builtin_action_types/jira/types'; -import { - PushToServiceApiParams as ResilientPushToServiceApiParams, - Incident as ResilientIncident, -} from '../../../../actions/server/builtin_action_types/resilient/types'; -import { - PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams, - ServiceNowITSMIncident, -} from '../../../../actions/server/builtin_action_types/servicenow/types'; -import { ResilientFieldsRT } from './resilient'; -import { ServiceNowFieldsRT } from './servicenow'; -import { JiraFieldsRT } from './jira'; - -// Formerly imported from security_solution -export interface ElasticUser { - readonly email?: string | null; - readonly fullName?: string | null; - readonly username?: string | null; -} - -export { - JiraPushToServiceApiParams, - ResilientPushToServiceApiParams, - ServiceNowITSMPushToServiceApiParams, -}; -export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; -export type PushToServiceApiParams = - | JiraPushToServiceApiParams - | ResilientPushToServiceApiParams - | ServiceNowITSMPushToServiceApiParams; const ActionTypeRT = rt.union([ rt.literal('append'), @@ -52,6 +17,7 @@ const CaseFieldRT = rt.union([ rt.literal('description'), rt.literal('comments'), ]); + const ThirdPartyFieldRT = rt.union([rt.string, rt.literal('not_mapped')]); export type ActionType = rt.TypeOf; export type CaseField = rt.TypeOf; @@ -62,9 +28,11 @@ export const ConnectorMappingsAttributesRT = rt.type({ source: CaseFieldRT, target: ThirdPartyFieldRT, }); + export const ConnectorMappingsRt = rt.type({ mappings: rt.array(ConnectorMappingsAttributesRT), }); + export type ConnectorMappingsAttributes = rt.TypeOf; export type ConnectorMappings = rt.TypeOf; @@ -76,125 +44,12 @@ const ConnectorFieldRt = rt.type({ required: rt.boolean, type: FieldTypeRT, }); + export type ConnectorField = rt.TypeOf; -export const ConnectorRequestParamsRt = rt.type({ - connector_id: rt.string, -}); -export const GetFieldsRequestQueryRt = rt.type({ - connector_type: rt.string, -}); + const GetFieldsResponseRt = rt.type({ defaultMappings: rt.array(ConnectorMappingsAttributesRT), fields: rt.array(ConnectorFieldRt), }); -export type GetFieldsResponse = rt.TypeOf; - -export type ExternalServiceParams = Record; - -export interface PipedField { - actionType: string; - key: string; - pipes: string[]; - value: string; -} -export interface PrepareFieldsForTransformArgs { - defaultPipes: string[]; - mappings: ConnectorMappingsAttributes[]; - params: ServiceConnectorCaseParams; -} -export interface EntityInformation { - createdAt: string; - createdBy: ElasticUser; - updatedAt: string | null; - updatedBy: ElasticUser | null; -} -export interface TransformerArgs { - date?: string; - previousValue?: string; - user?: string; - value: string; -} - -export type Transformer = (args: TransformerArgs) => TransformerArgs; -export interface TransformFieldsArgs { - currentIncident?: S; - fields: PipedField[]; - params: P; -} - -export const ServiceConnectorUserParams = rt.type({ - fullName: rt.union([rt.string, rt.null]), - username: rt.string, -}); - -export const ServiceConnectorCommentParamsRt = rt.type({ - commentId: rt.string, - comment: rt.string, - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), -}); -export const ServiceConnectorBasicCaseParamsRt = rt.type({ - comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]), - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - description: rt.union([rt.string, rt.null]), - externalId: rt.union([rt.string, rt.null]), - savedObjectId: rt.string, - title: rt.string, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), -}); - -export const ConnectorPartialFieldsRt = rt.partial({ - ...JiraFieldsRT.props, - ...ResilientFieldsRT.props, - ...ServiceNowFieldsRT.props, -}); - -export const ServiceConnectorCaseParamsRt = rt.intersection([ - ServiceConnectorBasicCaseParamsRt, - ConnectorPartialFieldsRt, -]); -export const ServiceConnectorCaseResponseRt = rt.intersection([ - rt.type({ - title: rt.string, - id: rt.string, - pushedDate: rt.string, - url: rt.string, - }), - rt.partial({ - comments: rt.array( - rt.intersection([ - rt.type({ - commentId: rt.string, - pushedDate: rt.string, - }), - rt.partial({ externalCommentId: rt.string }), - ]) - ), - }), -]); -export type ServiceConnectorBasicCaseParams = rt.TypeOf; -export type ServiceConnectorCaseParams = rt.TypeOf; -export type ServiceConnectorCaseResponse = rt.TypeOf; -export type ServiceConnectorCommentParams = rt.TypeOf; - -export const PostPushRequestRt = rt.type({ - connector_type: rt.string, - params: ServiceConnectorCaseParamsRt, -}); - -export type PostPushRequest = rt.TypeOf; - -export interface SimpleComment { - comment: string; - commentId: string; -} - -export interface MapIncident { - incident: ExternalServiceParams; - comments: SimpleComment[]; -} +export type GetFieldsResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow.ts b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts similarity index 76% rename from x-pack/plugins/case/common/api/connectors/servicenow.ts rename to x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts index fc4e8f9aa09a37..2e86a26971aaaa 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts @@ -7,10 +7,10 @@ import * as rt from 'io-ts'; -export const ServiceNowFieldsRT = rt.type({ +export const ServiceNowITSMFieldsRT = rt.type({ impact: rt.union([rt.string, rt.null]), severity: rt.union([rt.string, rt.null]), urgency: rt.union([rt.string, rt.null]), }); -export type ServiceNowFieldsType = rt.TypeOf; +export type ServiceNowITSMFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts new file mode 100644 index 00000000000000..749abdea87437b --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const ServiceNowSIRFieldsRT = rt.type({ + category: rt.union([rt.string, rt.null]), + destIp: rt.union([rt.boolean, rt.null]), + malwareHash: rt.union([rt.boolean, rt.null]), + malwareUrl: rt.union([rt.boolean, rt.null]), + priority: rt.union([rt.string, rt.null]), + sourceIp: rt.union([rt.boolean, rt.null]), + subcategory: rt.union([rt.string, rt.null]), +}); + +export type ServiceNowSIRFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index f9de74f45de465..24c4756a1596b4 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -10,7 +10,7 @@ import { CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, - CASE_CONFIGURE_PUSH_URL, + CASE_PUSH_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -28,6 +28,6 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str export const getCaseUserActionUrl = (id: string): string => { return CASE_USER_ACTIONS_URL.replace('{case_id}', id); }; -export const getCaseConfigurePushUrl = (id: string): string => { - return CASE_CONFIGURE_PUSH_URL.replace('{connector_id}', id); +export const getCasePushUrl = (caseId: string, connectorId: string): string => { + return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId); }; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 231ff9ef2dc4d4..92dd2312f1ecf3 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -15,10 +15,9 @@ export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; -export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; -export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; +export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; export const CASE_STATUS_URL = `${CASES_URL}/status`; export const CASE_TAGS_URL = `${CASES_URL}/tags`; @@ -30,12 +29,14 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; -export const SERVICENOW_ACTION_TYPE_ID = '.servicenow'; +export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; +export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; export const JIRA_ACTION_TYPE_ID = '.jira'; export const RESILIENT_ACTION_TYPE_ID = '.resilient'; export const SUPPORTED_CONNECTORS = [ - SERVICENOW_ACTION_TYPE_ID, + SERVICENOW_ITSM_ACTION_TYPE_ID, + SERVICENOW_SIR_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID, ]; diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts new file mode 100644 index 00000000000000..718dd327aa08c8 --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { CaseClientGetAlerts, CaseClientFactoryArguments } from '../types'; +import { CaseClientGetAlertsResponse } from './types'; + +export const get = ({ alertsService, request, context }: CaseClientFactoryArguments) => async ({ + ids, +}: CaseClientGetAlerts): Promise => { + const securitySolutionClient = context?.securitySolution?.getAppClient(); + if (securitySolutionClient == null) { + throw Boom.notFound('securitySolutionClient client have not been found'); + } + + if (ids.length === 0) { + return []; + } + + const index = securitySolutionClient.getSignalsIndex(); + const alerts = await alertsService.getAlerts({ ids, index, request }); + return alerts.hits.hits.map((alert) => ({ + id: alert._id, + index: alert._index, + ...alert._source, + })); +}; diff --git a/x-pack/plugins/case/server/client/alerts/types.ts b/x-pack/plugins/case/server/client/alerts/types.ts new file mode 100644 index 00000000000000..7b9d4a8856f48a --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface Alert { + id: string; + index: string; + destination?: { + ip: string; + }; + source?: { + ip: string; + }; +} + +export type CaseClientGetAlertsResponse = Alert[]; diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts new file mode 100644 index 00000000000000..c1901ccaae511c --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { CaseResponseRt, CaseResponse } from '../../../common/api'; +import { CaseClientGet, CaseClientFactoryArguments } from '../types'; + +export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArguments) => async ({ + id, + includeComments = false, +}: CaseClientGet): Promise => { + const theCase = await caseService.getCase({ + client: savedObjectsClient, + caseId: id, + }); + + if (!includeComments) { + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + }) + ); + } + + const theComments = await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + }) + ); +}; diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts new file mode 100644 index 00000000000000..57e2d4373a52b8 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CommentResponse, + CommentType, + ConnectorMappingsAttributes, + CaseUserActionsResponse, +} from '../../../common/api'; + +import { BasicParams } from './types'; + +export const updateUser = { + updated_at: '2020-03-13T08:34:53.450Z', + updated_by: { full_name: 'Another User', username: 'another', email: 'elastic@elastic.co' }, +}; + +const entity = { + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { full_name: 'Elastic User', username: 'elastic', email: 'elastic@elastic.co' }, + updatedAt: null, + updatedBy: null, +}; + +export const comment: CommentResponse = { + id: 'mock-comment-1', + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user as const, + created_at: '2019-11-25T21:55:00.177Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2019-11-25T21:55:00.177Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + version: 'WzEsMV0=', +}; + +export const commentAlert: CommentResponse = { + id: 'mock-comment-1', + alertId: 'alert-id-1', + index: 'alert-index-1', + type: CommentType.alert as const, + created_at: '2019-11-25T21:55:00.177Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2019-11-25T21:55:00.177Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + version: 'WzEsMV0=', +}; + +export const defaultPipes = ['informationCreated']; +export const basicParams: BasicParams = { + description: 'a description', + title: 'a title', + ...entity, +}; + +export const mappings: ConnectorMappingsAttributes[] = [ + { + source: 'title', + target: 'short_description', + action_type: 'overwrite', + }, + { + source: 'description', + target: 'description', + action_type: 'append', + }, + { + source: 'comments', + target: 'comments', + action_type: 'append', + }, +]; + +export const userActions: CaseUserActionsResponse = [ + { + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + action: 'create', + action_at: '2021-02-03T17:41:03.771Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"id":"456","name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', + old_value: null, + action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + { + action_field: ['pushed'], + action: 'push-to-service', + action_at: '2021-02-03T17:41:26.108Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + old_value: null, + action_id: '0a801750-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:44:21.067Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: '{"type":"alert","alertId":"alert-id-1","index":".siem-signals-default-000008"}', + old_value: null, + action_id: '7373eb60-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: 'comment-alert-1', + }, + { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:44:33.078Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: '{"type":"alert","alertId":"alert-id-2","index":".siem-signals-default-000008"}', + old_value: null, + action_id: '7abc6410-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: 'comment-alert-2', + }, + { + action_field: ['pushed'], + action: 'push-to-service', + action_at: '2021-02-03T17:45:29.400Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + old_value: null, + action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:48:30.616Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: '{"comment":"a comment!","type":"user"}', + old_value: null, + action_id: '0818e5e0-6648-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: 'comment-user-1', + }, +]; diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts new file mode 100644 index 00000000000000..f329fb4d00d077 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; + +import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { flattenCaseSavedObject } from '../../routes/api/utils'; + +import { + ActionConnector, + CaseResponseRt, + CaseResponse, + CaseStatuses, + ExternalServiceResponse, + ESCaseAttributes, + CommentAttributes, +} from '../../../common/api'; +import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; + +import { CaseClientPush, CaseClientFactoryArguments } from '../types'; +import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; + +const createError = (e: Error | BoomType, message: string): Error | BoomType => { + if (isBoom(e)) { + e.message = message; + e.output.payload.message = message; + return e; + } + + return Error(message); +}; + +export const push = ({ + savedObjectsClient, + caseService, + caseConfigureService, + userActionService, + request, + response, +}: CaseClientFactoryArguments) => async ({ + actionsClient, + caseClient, + caseId, + connectorId, +}: CaseClientPush): Promise => { + /* Start of push to external service */ + let theCase; + let connector; + let userActions; + let alerts; + let connectorMappings; + let externalServiceIncident; + + try { + [theCase, connector, userActions] = await Promise.all([ + caseClient.get({ id: caseId, includeComments: true }), + actionsClient.get({ id: connectorId }), + caseClient.getUserActions({ caseId }), + ]); + } catch (e) { + const message = `Error getting case and/or connector and/or user actions: ${e.message}`; + throw createError(e, message); + } + + // We need to change the logic when we support subcases + if (theCase?.status === CaseStatuses.closed) { + throw Boom.conflict( + `This case ${theCase.title} is closed. You can not pushed if the case is closed.` + ); + } + + try { + alerts = await caseClient.getAlerts({ + ids: theCase?.comments?.filter(isCommentAlertType).map((comment) => comment.alertId) ?? [], + }); + } catch (e) { + throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); + } + + try { + connectorMappings = await caseClient.getMappings({ + actionsClient, + caseClient, + connectorId: connector.id, + connectorType: connector.actionTypeId, + }); + } catch (e) { + const message = `Error getting mapping for connector with id ${connector.id}: ${e.message}`; + throw createError(e, message); + } + + try { + externalServiceIncident = await createIncident({ + actionsClient, + theCase, + userActions, + connector: connector as ActionConnector, + mappings: connectorMappings, + alerts, + }); + } catch (e) { + const message = `Error creating incident for case with id ${theCase.id}: ${e.message}`; + throw createError(e, message); + } + + const pushRes = await actionsClient.execute({ + actionId: connector?.id ?? '', + params: { + subAction: 'pushToService', + subActionParams: externalServiceIncident, + }, + }); + + if (pushRes.status === 'error') { + throw Boom.failedDependency( + pushRes.serviceMessage ?? pushRes.message ?? 'Error pushing to service' + ); + } + + /* End of push to external service */ + + /* Start of update case with push information */ + let user; + let myCase; + let myCaseConfigure; + let comments; + + try { + [user, myCase, myCaseConfigure, comments] = await Promise.all([ + caseService.getUser({ request, response }), + caseService.getCase({ + client: savedObjectsClient, + caseId, + }), + caseConfigureService.find({ client: savedObjectsClient }), + caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId, + options: { + fields: [], + page: 1, + perPage: theCase?.totalComment ?? 0, + }, + }), + ]); + } catch (e) { + const message = `Error getting user and/or case and/or case configuration and/or case comments: ${e.message}`; + throw createError(e, message); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const pushedDate = new Date().toISOString(); + const externalServiceResponse = pushRes.data as ExternalServiceResponse; + + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + connector_id: connector.id, + connector_name: connector.name, + external_id: externalServiceResponse.id, + external_title: externalServiceResponse.title, + external_url: externalServiceResponse.url, + }; + + let updatedCase: SavedObjectsUpdateResponse; + let updatedComments: SavedObjectsBulkUpdateResponse; + + try { + [updatedCase, updatedComments] = await Promise.all([ + caseService.patchCase({ + client: savedObjectsClient, + caseId, + updatedAttributes: { + ...(myCaseConfigure.total > 0 && + myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? { + status: CaseStatuses.closed, + closed_at: pushedDate, + closed_by: { email, full_name, username }, + } + : {}), + external_service: externalService, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: myCase.version, + }), + + caseService.patchComments({ + client: savedObjectsClient, + comments: comments.saved_objects + .filter((comment) => comment.attributes.pushed_at == null) + .map((comment) => ({ + commentId: comment.id, + updatedAttributes: { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + }, + version: comment.version, + })), + }), + + userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + ...(myCaseConfigure.total > 0 && + myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? [ + buildCaseUserActionItem({ + action: 'update', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['status'], + newValue: CaseStatuses.closed, + oldValue: myCase.attributes.status, + }), + ] + : []), + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['pushed'], + newValue: JSON.stringify(externalService), + }), + ], + }), + ]); + } catch (e) { + const message = `Error updating case and/or comments and/or creating user action: ${e.message}`; + throw createError(e, message); + } + /* End of update case with push information */ + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments: comments.saved_objects.map((origComment) => { + const updatedComment = updatedComments.saved_objects.find((c) => c.id === origComment.id); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + ...getCommentContextFromAttributes(origComment.attributes), + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }), + }) + ); +}; diff --git a/x-pack/plugins/case/server/client/cases/types.ts b/x-pack/plugins/case/server/client/cases/types.ts new file mode 100644 index 00000000000000..f1d56e7132bd11 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/types.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import { + PushToServiceApiParams as JiraPushToServiceApiParams, + Incident as JiraIncident, +} from '../../../../actions/server/builtin_action_types/jira/types'; +import { + PushToServiceApiParams as ResilientPushToServiceApiParams, + Incident as ResilientIncident, +} from '../../../../actions/server/builtin_action_types/resilient/types'; +import { + PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams, + PushToServiceApiParamsSIR as ServiceNowSIRPushToServiceApiParams, + ServiceNowITSMIncident, +} from '../../../../actions/server/builtin_action_types/servicenow/types'; +import { CaseResponse, ConnectorMappingsAttributes } from '../../../common/api'; + +export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; +export type PushToServiceApiParams = + | JiraPushToServiceApiParams + | ResilientPushToServiceApiParams + | ServiceNowITSMPushToServiceApiParams + | ServiceNowSIRPushToServiceApiParams; + +export type ExternalServiceParams = Record; + +export interface BasicParams { + title: CaseResponse['title']; + description: CaseResponse['description']; + createdAt: CaseResponse['created_at']; + createdBy: CaseResponse['created_by']; + updatedAt: CaseResponse['updated_at']; + updatedBy: CaseResponse['updated_by']; +} + +export interface PipedField { + actionType: string; + key: string; + pipes: string[]; + value: string; +} +export interface PrepareFieldsForTransformArgs { + defaultPipes: string[]; + mappings: ConnectorMappingsAttributes[]; + params: { title: string; description: string }; +} +export interface EntityInformation { + createdAt: CaseResponse['created_at']; + createdBy: CaseResponse['created_by']; + updatedAt: CaseResponse['updated_at']; + updatedBy: CaseResponse['updated_by']; +} +export interface TransformerArgs { + date?: string; + previousValue?: string; + user?: string; + value: string; +} + +export type Transformer = (args: TransformerArgs) => TransformerArgs; +export interface TransformFieldsArgs { + currentIncident?: S; + fields: PipedField[]; + params: P; +} + +export interface ExternalServiceComment { + comment: string; + commentId: string; +} + +export interface MapIncident { + incident: ExternalServiceParams; + comments: ExternalServiceComment[]; +} diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts similarity index 52% rename from x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts rename to x-pack/plugins/case/server/client/cases/utils.test.ts index 5114703c609634..dca2c34602678b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -5,34 +5,45 @@ * 2.0. */ +import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; +import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { mockCases } from '../../routes/api/__fixtures__'; + +import { BasicParams, ExternalServiceParams, Incident } from './types'; +import { + comment as commentObj, + mappings, + defaultPipes, + basicParams, + userActions, + commentAlert, +} from './mock'; + import { - mapIncident, + createIncident, + getLatestPushInfo, prepareFieldsForTransformation, - serviceFormatter, transformComments, transformers, transformFields, } from './utils'; -import { comment as commentObj, mappings, defaultPipes, params, updateUser } from './mock'; -import { - ConnectorTypes, - ExternalServiceParams, - Incident, - ServiceConnectorCaseParams, -} from '../../../../../common/api/connectors'; -import { actionsClientMock } from '../../../../../../actions/server/actions_client.mock'; -import { mappings as mappingsMock } from '../../../../client/configure/mock'; -const formatComment = { commentId: commentObj.commentId, comment: commentObj.comment }; -const serviceNowParams = params[ConnectorTypes.servicenow] as ServiceConnectorCaseParams; -describe('api/cases/configure/utils', () => { +const formatComment = { + commentId: commentObj.id, + comment: 'Wow, good luck catching that bad meanie!', +}; + +const params = { ...basicParams }; + +describe('utils', () => { describe('prepareFieldsForTransformation', () => { test('prepare fields with defaults', () => { const res = prepareFieldsForTransformation({ defaultPipes, - params: serviceNowParams, + params, mappings, }); + expect(res).toEqual([ { actionType: 'overwrite', @@ -53,8 +64,9 @@ describe('api/cases/configure/utils', () => { const res = prepareFieldsForTransformation({ defaultPipes: ['myTestPipe'], mappings, - params: serviceNowParams, + params, }); + expect(res).toEqual([ { actionType: 'overwrite', @@ -71,16 +83,17 @@ describe('api/cases/configure/utils', () => { ]); }); }); + describe('transformFields', () => { test('transform fields for creation correctly', () => { const fields = prepareFieldsForTransformation({ defaultPipes, mappings, - params: serviceNowParams, + params, }); - const res = transformFields({ - params: serviceNowParams, + const res = transformFields({ + params, fields, }); @@ -92,18 +105,19 @@ describe('api/cases/configure/utils', () => { test('transform fields for update correctly', () => { const fields = prepareFieldsForTransformation({ - params: serviceNowParams, + params, mappings, defaultPipes: ['informationUpdated'], }); - const res = transformFields({ + const res = transformFields({ params: { - ...serviceNowParams, + ...params, updatedAt: '2020-03-15T08:34:53.450Z', updatedBy: { username: 'anotherUser', - fullName: 'Another User', + full_name: 'Another User', + email: 'elastic@elastic.co', }, }, fields, @@ -112,6 +126,7 @@ describe('api/cases/configure/utils', () => { description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', }, }); + expect(res).toEqual({ short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', description: @@ -121,13 +136,13 @@ describe('api/cases/configure/utils', () => { test('add newline character to description', () => { const fields = prepareFieldsForTransformation({ - params: serviceNowParams, + params, mappings, defaultPipes: ['informationUpdated'], }); - const res = transformFields({ - params: serviceNowParams, + const res = transformFields({ + params, fields, currentIncident: { short_description: 'first title', @@ -141,13 +156,13 @@ describe('api/cases/configure/utils', () => { const fields = prepareFieldsForTransformation({ defaultPipes, mappings, - params: serviceNowParams, + params, }); - const res = transformFields({ + const res = transformFields({ params: { - ...serviceNowParams, - createdBy: { fullName: '', username: 'elastic' }, + ...params, + createdBy: { full_name: '', username: 'elastic', email: 'elastic@elastic.co' }, }, fields, }); @@ -162,14 +177,14 @@ describe('api/cases/configure/utils', () => { const fields = prepareFieldsForTransformation({ defaultPipes: ['informationUpdated'], mappings, - params: serviceNowParams, + params, }); - const res = transformFields({ + const res = transformFields({ params: { - ...serviceNowParams, + ...params, updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: '' }, + updatedBy: { username: 'anotherUser', full_name: '', email: 'elastic@elastic.co' }, }, fields, }); @@ -180,6 +195,7 @@ describe('api/cases/configure/utils', () => { }); }); }); + describe('transformComments', () => { test('transform creation comments', () => { const comments = [commentObj]; @@ -187,7 +203,7 @@ describe('api/cases/configure/utils', () => { expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (created at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`, + comment: `${formatComment.comment} (created at ${comments[0].created_at} by ${comments[0].created_by.full_name})`, }, ]); }); @@ -196,14 +212,19 @@ describe('api/cases/configure/utils', () => { const comments = [ { ...commentObj, - ...updateUser, + updated_at: '2020-03-13T08:34:53.450Z', + updated_by: { + full_name: 'Another User', + username: 'another', + email: 'elastic@elastic.co', + }, }, ]; const res = transformComments(comments, ['informationUpdated']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (updated at ${updateUser.updatedAt} by ${updateUser.updatedBy.fullName})`, + comment: `${formatComment.comment} (updated at ${comments[0].updated_at} by ${comments[0].updated_by.full_name})`, }, ]); }); @@ -214,19 +235,19 @@ describe('api/cases/configure/utils', () => { expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`, + comment: `${formatComment.comment} (added at ${comments[0].created_at} by ${comments[0].created_by.full_name})`, }, ]); }); test('transform comments without fullname', () => { - const comments = [{ ...commentObj, createdBy: { username: commentObj.createdBy.username } }]; - // @ts-ignore testing no fullName + const comments = [{ ...commentObj, createdBy: { username: commentObj.created_by.username } }]; + // @ts-ignore testing no full_name const res = transformComments(comments, ['informationAdded']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.username})`, + comment: `${formatComment.comment} (added at ${comments[0].created_at} by ${comments[0].created_by.username})`, }, ]); }); @@ -235,15 +256,15 @@ describe('api/cases/configure/utils', () => { const comments = [ { ...commentObj, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic2', username: 'elastic' }, + updated_at: '2020-04-13T08:34:53.450Z', + updated_by: { full_name: 'Elastic2', username: 'elastic', email: 'elastic@elastic.co' }, }, ]; const res = transformComments(comments, ['informationAdded']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.fullName})`, + comment: `${formatComment.comment} (added at ${comments[0].updated_at} by ${comments[0].updated_by.full_name})`, }, ]); }); @@ -252,19 +273,20 @@ describe('api/cases/configure/utils', () => { const comments = [ { ...commentObj, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: '', username: 'elastic2' }, + updated_at: '2020-04-13T08:34:53.450Z', + updated_by: { full_name: '', username: 'elastic2', email: 'elastic@elastic.co' }, }, ]; const res = transformComments(comments, ['informationAdded']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.username})`, + comment: `${formatComment.comment} (added at ${comments[0].updated_at} by ${comments[0].updated_by.username})`, }, ]); }); }); + describe('transformers', () => { const { informationCreated, informationUpdated, informationAdded, append } = transformers; describe('informationCreated', () => { @@ -389,142 +411,291 @@ describe('api/cases/configure/utils', () => { }); }); }); - describe('mapIncident', () => { + + describe('createIncident', () => { let actionsMock = actionsClientMock.create(); - it('maps an external incident', async () => { - const res = await mapIncident( - actionsMock, - '123', - ConnectorTypes.servicenow, - mappingsMock[ConnectorTypes.servicenow], - serviceNowParams - ); + const theCase = { + ...flattenCaseSavedObject({ + savedObject: mockCases[0], + }), + comments: [commentObj], + totalComments: 1, + }; + + const connector = { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + }; + + it('creates an external incident', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase, + userActions: [], + connector, + mappings, + alerts: [], + }); + expect(res).toEqual({ incident: { - description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + priority: null, + labels: ['defacement'], + issueType: null, + parent: null, + short_description: + 'Super Bad Security Issue (created at 2019-11-25T21:54:48.952Z by elastic)', + description: + 'This is a brand new case of a bad meanie defacing data (created at 2019-11-25T21:54:48.952Z by elastic)', externalId: null, - impact: '3', - severity: '1', - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - urgency: '2', }, - comments: [ + comments: [], + }); + }); + + it('it creates comments correctly', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [{ ...commentObj, id: 'comment-user-1' }], + }, + userActions, + connector, + mappings, + alerts: [], + }); + + expect(res.comments).toEqual([ + { + comment: + 'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-user-1', + }, + ]); + }); + + it('it does NOT creates comments when mapping is nothing', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [{ ...commentObj, id: 'comment-user-1' }], + }, + userActions, + connector, + mappings: [ + mappings[0], + mappings[1], { - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + source: 'comments', + target: 'comments', + action_type: 'nothing', }, ], + alerts: [], }); + + expect(res.comments).toEqual([]); }); - it('throws error if invalid service', async () => { - await mapIncident( - actionsMock, - '123', - 'invalid', - mappingsMock[ConnectorTypes.servicenow], - serviceNowParams - ).catch((e) => { - expect(e).not.toBeNull(); - expect(e).toEqual(new Error(`Invalid service`)); + + it('it creates comments of type alert correctly', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [ + { ...commentObj, id: 'comment-user-1' }, + { ...commentAlert, id: 'comment-alert-1' }, + { ...commentAlert, id: 'comment-alert-2' }, + ], + }, + // Remove second push + userActions: userActions.filter((item, index) => index !== 4), + connector, + mappings: [ + ...mappings, + { + source: 'comments', + target: 'comments', + action_type: 'nothing', + }, + ], + alerts: [], }); + + expect(res.comments).toEqual([ + { + comment: + 'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-user-1', + }, + { + comment: + 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-alert-1', + }, + { + comment: + 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-alert-2', + }, + ]); }); + it('updates an existing incident', async () => { const existingIncidentData = { - description: 'fun description', - impact: '3', - severity: '3', + priority: null, + issueType: null, + parent: null, short_description: 'fun title', - urgency: '3', + description: 'fun description', }; + const execute = jest.fn().mockReturnValue(existingIncidentData); actionsMock = { ...actionsMock, execute }; - const res = await mapIncident( - actionsMock, - '123', - ConnectorTypes.servicenow, - mappingsMock[ConnectorTypes.servicenow], - { ...serviceNowParams, externalId: '123' } - ); + + const res = await createIncident({ + actionsClient: actionsMock, + theCase, + userActions, + connector, + mappings, + alerts: [], + }); + expect(res).toEqual({ incident: { - description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - externalId: '123', - impact: '3', - severity: '1', - short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - urgency: '2', + priority: null, + labels: ['defacement'], + issueType: null, + parent: null, + description: + 'fun description \r\nThis is a brand new case of a bad meanie defacing data (updated at 2019-11-25T21:54:48.952Z by elastic)', + externalId: 'external-id', + short_description: + 'Super Bad Security Issue (updated at 2019-11-25T21:54:48.952Z by elastic)', }, - comments: [ - { - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - }, - ], + comments: [], }); }); + it('throws error when existing incident throws', async () => { + expect.assertions(2); const execute = jest.fn().mockImplementation(() => { throw new Error('exception'); }); + actionsMock = { ...actionsMock, execute }; - await mapIncident( - actionsMock, - '123', - ConnectorTypes.servicenow, - mappingsMock[ConnectorTypes.servicenow], - { ...serviceNowParams, externalId: '123' } - ).catch((e) => { + createIncident({ + actionsClient: actionsMock, + theCase, + userActions, + connector, + mappings, + alerts: [], + }).catch((e) => { expect(e).not.toBeNull(); expect(e).toEqual( new Error( - `Retrieving Incident by id 123 from ServiceNow failed with exception: Error: exception` + `Retrieving Incident by id external-id from .jira failed with exception: Error: exception` ) ); }); }); - }); - const connectors = [ - { - name: ConnectorTypes.jira, - result: { - incident: { - issueType: '10003', - parent: '5002', - priority: 'Highest', - }, - thirdPartyName: 'Jira', - }, - }, - { - name: ConnectorTypes.resilient, - result: { - incident: { - incidentTypes: ['10003'], - severityCode: '1', - }, - thirdPartyName: 'Resilient', - }, - }, - { - name: ConnectorTypes.servicenow, - result: { - incident: { - impact: '3', - severity: '1', - urgency: '2', - }, - thirdPartyName: 'ServiceNow', - }, - }, - ]; - describe('serviceFormatter', () => { - connectors.forEach((c) => - it(`formats ${c.name}`, () => { - const caseParams = params[c.name] as ServiceConnectorCaseParams; - const res = serviceFormatter(c.name, caseParams); - expect(res).toEqual(c.result); - }) - ); + it('throws error if connector is not supported', async () => { + expect.assertions(2); + createIncident({ + actionsClient: actionsMock, + theCase, + userActions, + connector: { ...connector, actionTypeId: 'not-supported' }, + mappings, + alerts: [], + }).catch((e) => { + expect(e).not.toBeNull(); + expect(e).toEqual(new Error('Invalid external service')); + }); + }); + + describe('getLatestPushInfo', () => { + it('it returns the latest push information correctly', async () => { + const res = getLatestPushInfo('456', userActions); + expect(res).toEqual({ + index: 4, + pushedInfo: { + connector_id: '456', + connector_name: 'ServiceNow SN', + external_id: 'external-id', + external_title: 'SIR0010037', + external_url: + 'https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id', + pushed_at: '2021-02-03T17:45:29.400Z', + pushed_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + }, + }); + }); + + it('it returns null when there are not actions', async () => { + const res = getLatestPushInfo('456', []); + expect(res).toBe(null); + }); + + it('it returns null when there are no push user action', async () => { + const res = getLatestPushInfo('456', [userActions[0]]); + expect(res).toBe(null); + }); + + it('it returns the correct push information when with multiple push on different connectors', async () => { + const res = getLatestPushInfo('456', [ + ...userActions.slice(0, 3), + { + action_field: ['pushed'], + action: 'push-to-service', + action_at: '2021-02-03T17:45:29.400Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + // The connector id is 123 + '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"123","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + old_value: null, + action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + ]); + + expect(res).toEqual({ + index: 1, + pushedInfo: { + connector_id: '456', + connector_name: 'ServiceNow SN', + external_id: 'external-id', + external_title: 'SIR0010037', + external_url: + 'https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id', + pushed_at: '2021-02-03T17:41:26.108Z', + pushed_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + }, + }); + }); + }); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts similarity index 50% rename from x-pack/plugins/case/server/routes/api/cases/configure/utils.ts rename to x-pack/plugins/case/server/client/cases/utils.ts index 01a1a580bd78fe..6974fd4ffa2883 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -8,46 +8,118 @@ import { i18n } from '@kbn/i18n'; import { flow } from 'lodash'; import { - ServiceConnectorCaseParams, - ServiceConnectorCommentParams, + ActionConnector, + CaseResponse, + CaseFullExternalService, + CaseUserActionsResponse, + CommentResponse, + CommentResponseAlertsType, + CommentType, ConnectorMappingsAttributes, ConnectorTypes, + CommentAttributes, + CommentRequestUserType, + CommentRequestAlertType, +} from '../../../common/api'; +import { ActionsClient } from '../../../../actions/server'; +import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; +import { CaseClientGetAlertsResponse } from '../../client/alerts/types'; +import { + BasicParams, EntityInformation, ExternalServiceParams, + ExternalServiceComment, Incident, - JiraPushToServiceApiParams, MapIncident, PipedField, PrepareFieldsForTransformArgs, PushToServiceApiParams, - ResilientPushToServiceApiParams, - ServiceNowITSMPushToServiceApiParams, - SimpleComment, Transformer, TransformerArgs, TransformFieldsArgs, -} from '../../../../../common/api'; -import { ActionsClient } from '../../../../../../actions/server'; -export const mapIncident = async ( - actionsClient: ActionsClient, +} from './types'; + +export const getLatestPushInfo = ( connectorId: string, - connectorType: string, - mappings: ConnectorMappingsAttributes[], - params: ServiceConnectorCaseParams -): Promise => { - const { comments: caseComments, externalId } = params; + userActions: CaseUserActionsResponse +): { index: number; pushedInfo: CaseFullExternalService } | null => { + for (const [index, action] of [...userActions].reverse().entries()) { + if (action.action === 'push-to-service' && action.new_value) + try { + const pushedInfo = JSON.parse(action.new_value); + if (pushedInfo.connector_id === connectorId) { + // We returned the index of the element in the userActions array. + // As we traverse the userActions in reverse we need to calculate the index of a normal traversal + return { index: userActions.length - index - 1, pushedInfo }; + } + } catch (e) { + // Silence JSON parse errors + } + } + + return null; +}; + +const isConnectorSupported = (connectorId: string): connectorId is FormatterConnectorTypes => + Object.values(ConnectorTypes).includes(connectorId as ConnectorTypes); + +const getCommentContent = (comment: CommentResponse): string => { + if (comment.type === CommentType.user) { + return comment.comment; + } else if (comment.type === CommentType.alert) { + return `Alert with id ${comment.alertId} added to case`; + } + + return ''; +}; + +interface CreateIncidentArgs { + actionsClient: ActionsClient; + theCase: CaseResponse; + userActions: CaseUserActionsResponse; + connector: ActionConnector; + mappings: ConnectorMappingsAttributes[]; + alerts: CaseClientGetAlertsResponse; +} + +export const createIncident = async ({ + actionsClient, + theCase, + userActions, + connector, + mappings, + alerts, +}: CreateIncidentArgs): Promise => { + const { + comments: caseComments, + title, + description, + created_at: createdAt, + created_by: createdBy, + updated_at: updatedAt, + updated_by: updatedBy, + } = theCase; + + if (!isConnectorSupported(connector.actionTypeId)) { + throw new Error('Invalid external service'); + } + + const params = { title, description, createdAt, createdBy, updatedAt, updatedBy }; + const latestPushInfo = getLatestPushInfo(connector.id, userActions); + const externalId = latestPushInfo?.pushedInfo?.external_id ?? null; const defaultPipes = externalId ? ['informationUpdated'] : ['informationCreated']; let currentIncident: ExternalServiceParams | undefined; - const service = serviceFormatter(connectorType, params); - if (service == null) { - throw new Error(`Invalid service`); - } - const thirdPartyName = service.thirdPartyName; - let incident: Partial = service.incident; + + const externalServiceFields = externalServiceFormatters[connector.actionTypeId].format( + theCase, + alerts + ); + let incident: Partial = { ...externalServiceFields }; + if (externalId) { try { currentIncident = ((await actionsClient.execute({ - actionId: connectorId, + actionId: connector.id, params: { subAction: 'getIncident', subActionParams: { externalId }, @@ -55,80 +127,56 @@ export const mapIncident = async ( })) as unknown) as ExternalServiceParams | undefined; } catch (ex) { throw new Error( - `Retrieving Incident by id ${externalId} from ${thirdPartyName} failed with exception: ${ex}` + `Retrieving Incident by id ${externalId} from ${connector.actionTypeId} failed with exception: ${ex}` ); } } + const fields = prepareFieldsForTransformation({ defaultPipes, mappings, params, }); - const transformedFields = transformFields< - ServiceConnectorCaseParams, - ExternalServiceParams, - Incident - >({ + + const transformedFields = transformFields({ params, fields, currentIncident, }); + incident = { ...incident, ...transformedFields, externalId }; - let comments: SimpleComment[] = []; - if (caseComments && Array.isArray(caseComments) && caseComments.length > 0) { + + const commentsIdsToBeUpdated = new Set( + userActions + .slice(latestPushInfo?.index ?? 0) + .filter( + (action, index) => + Array.isArray(action.action_field) && action.action_field[0] === 'comment' + ) + .map((action) => action.comment_id) + ); + const commentsToBeUpdated = caseComments?.filter((comment) => + commentsIdsToBeUpdated.has(comment.id) + ); + + let comments: ExternalServiceComment[] = []; + if (commentsToBeUpdated && Array.isArray(commentsToBeUpdated) && commentsToBeUpdated.length > 0) { const commentsMapping = mappings.find((m) => m.source === 'comments'); if (commentsMapping?.action_type !== 'nothing') { - comments = transformComments(caseComments, ['informationAdded']); + comments = transformComments(commentsToBeUpdated, ['informationAdded']); } } return { incident, comments }; }; -export const serviceFormatter = ( - connectorType: string, - params: unknown -): { thirdPartyName: string; incident: Partial } | null => { - switch (connectorType) { - case ConnectorTypes.jira: - const { - priority, - labels, - issueType, - parent, - } = params as JiraPushToServiceApiParams['incident']; - return { - incident: { priority, labels, issueType, parent }, - thirdPartyName: 'Jira', - }; - case ConnectorTypes.resilient: - const { incidentTypes, severityCode } = params as ResilientPushToServiceApiParams['incident']; - return { - incident: { incidentTypes, severityCode }, - thirdPartyName: 'Resilient', - }; - case ConnectorTypes.servicenow: - const { - severity, - urgency, - impact, - } = params as ServiceNowITSMPushToServiceApiParams['incident']; - return { - incident: { severity, urgency, impact }, - thirdPartyName: 'ServiceNow', - }; - default: - return null; - } -}; - export const getEntity = (entity: EntityInformation): string => (entity.updatedBy != null - ? entity.updatedBy.fullName - ? entity.updatedBy.fullName + ? entity.updatedBy.full_name + ? entity.updatedBy.full_name : entity.updatedBy.username : entity.createdBy != null - ? entity.createdBy.fullName - ? entity.createdBy.fullName + ? entity.createdBy.full_name + ? entity.createdBy.full_name : entity.createdBy.username : '') ?? ''; @@ -160,6 +208,7 @@ export const FIELD_INFORMATION = ( }); } }; + export const transformers: Record = { informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ value: `${value} ${FIELD_INFORMATION('create', date, user)}`, @@ -178,6 +227,7 @@ export const transformers: Record = { ...rest, }), }; + export const prepareFieldsForTransformation = ({ defaultPipes, mappings, @@ -226,14 +276,46 @@ export const transformFields = < }; export const transformComments = ( - comments: ServiceConnectorCommentParams[], + comments: CaseResponse['comments'] = [], pipes: string[] -): SimpleComment[] => +): ExternalServiceComment[] => comments.map((c) => ({ comment: flow(...pipes.map((p) => transformers[p]))({ - value: c.comment, - date: c.updatedAt ?? c.createdAt, - user: getEntity(c), + value: getCommentContent(c), + date: c.updated_at ?? c.created_at, + user: getEntity({ + createdAt: c.created_at, + createdBy: c.created_by, + updatedAt: c.updated_at, + updatedBy: c.updated_by, + }), }).value, - commentId: c.commentId, + commentId: c.id, })); + +export const isCommentAlertType = ( + comment: CommentResponse +): comment is CommentResponseAlertsType => comment.type === CommentType.alert; + +export const getCommentContextFromAttributes = ( + attributes: CommentAttributes +): CommentRequestUserType | CommentRequestAlertType => { + switch (attributes.type) { + case CommentType.user: + return { + type: CommentType.user, + comment: attributes.comment, + }; + case CommentType.alert: + return { + type: CommentType.alert, + alertId: attributes.alertId, + index: attributes.index, + }; + default: + return { + type: CommentType.user, + comment: '', + }; + } +}; diff --git a/x-pack/plugins/case/server/client/configure/mock.ts b/x-pack/plugins/case/server/client/configure/mock.ts index 46df0a7ac6756b..4d0c384e23e27a 100644 --- a/x-pack/plugins/case/server/client/configure/mock.ts +++ b/x-pack/plugins/case/server/client/configure/mock.ts @@ -70,7 +70,7 @@ export const mappings: TestMappings = { action_type: 'append', }, ], - [ConnectorTypes.servicenow]: [ + [ConnectorTypes.serviceNowITSM]: [ { source: 'title', target: 'short_description', @@ -611,7 +611,7 @@ export const formatFieldsTestData: FormatFieldsTestData[] = [ { id: 'upon_reject', name: 'Upon reject', required: false, type: 'text' }, ], fields: serviceNowFields, - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, }, ]; export const mockGetFieldsResponse = { diff --git a/x-pack/plugins/case/server/client/configure/utils.ts b/x-pack/plugins/case/server/client/configure/utils.ts index 2fc9e3d17801c2..7e91c2ae5a4d76 100644 --- a/x-pack/plugins/case/server/client/configure/utils.ts +++ b/x-pack/plugins/case/server/client/configure/utils.ts @@ -70,7 +70,9 @@ export const formatFields = (theData: unknown, theType: string): ConnectorField[ return normalizeJiraFields(theData as JiraGetFieldsResponse); case ConnectorTypes.resilient: return normalizeResilientFields(theData as ResilientGetFieldsResponse); - case ConnectorTypes.servicenow: + case ConnectorTypes.serviceNowITSM: + return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse); + case ConnectorTypes.serviceNowSIR: return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse); default: return []; @@ -97,10 +99,14 @@ const getPreferredFields = (theType: string) => { } else if (theType === ConnectorTypes.resilient) { title = 'name'; description = 'description'; - } else if (theType === ConnectorTypes.servicenow) { + } else if ( + theType === ConnectorTypes.serviceNowITSM || + theType === ConnectorTypes.serviceNowSIR + ) { title = 'short_description'; description = 'description'; } + return { title, description }; }; diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 095dc5102b7208..4daa4d1c0bd8b4 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createCaseClient } from '.'; import { @@ -17,29 +17,48 @@ import { } from '../services/mocks'; import { create } from './cases/create'; +import { get } from './cases/get'; import { update } from './cases/update'; +import { push } from './cases/push'; import { addComment } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; import type { CasesRequestHandlerContext } from '../types'; jest.mock('./cases/create'); jest.mock('./cases/update'); +jest.mock('./cases/get'); +jest.mock('./cases/push'); jest.mock('./comments/add'); jest.mock('./alerts/update_status'); +jest.mock('./alerts/get'); +jest.mock('./user_actions/get'); +jest.mock('./configure/get_fields'); +jest.mock('./configure/get_mappings'); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); const request = {} as KibanaRequest; +const response = kibanaResponseFactory; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); const context = {} as CasesRequestHandlerContext; const createMock = create as jest.Mock; +const getMock = get as jest.Mock; const updateMock = update as jest.Mock; +const pushMock = push as jest.Mock; const addCommentMock = addComment as jest.Mock; const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; +const getAlertsStatusMock = getAlerts as jest.Mock; +const getFieldsMock = getFields as jest.Mock; +const getMappingsMock = getMappings as jest.Mock; +const getUserActionsMock = getUserActions as jest.Mock; describe('createCaseClient()', () => { test('it creates the client correctly', async () => { @@ -50,49 +69,34 @@ describe('createCaseClient()', () => { connectorMappingsService, context, request, + response, savedObjectsClient, userActionService, }); - expect(createMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(updateMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(addCommentMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(updateAlertsStatusMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }); + [ + createMock, + getMock, + updateMock, + pushMock, + addCommentMock, + updateAlertsStatusMock, + getAlertsStatusMock, + getFieldsMock, + getMappingsMock, + getUserActionsMock, + ].forEach((method) => + expect(method).toHaveBeenCalledWith({ + caseConfigureService, + caseService, + connectorMappingsService, + request, + response, + savedObjectsClient, + userActionService, + alertsService, + context, + }) + ); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 1b9d3ce7ecb08d..e15b9fc766562b 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -5,73 +5,41 @@ * 2.0. */ -import { CaseClientFactoryArguments, CaseClient } from './types'; +import { + CaseClientFactoryArguments, + CaseClient, + CaseClientFactoryMethods, + CaseClientMethods, +} from './types'; import { create } from './cases/create'; +import { get } from './cases/get'; import { update } from './cases/update'; +import { push } from './cases/push'; import { addComment } from './comments/add'; import { getFields } from './configure/get_fields'; import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; export { CaseClient } from './types'; -export const createCaseClient = ({ - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - alertsService, - context, -}: CaseClientFactoryArguments): CaseClient => { - return { - create: create({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - update: update({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - addComment: addComment({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - getFields: getFields(), - getMappings: getMappings({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - updateAlertsStatus: updateAlertsStatus({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), +export const createCaseClient = (args: CaseClientFactoryArguments): CaseClient => { + const methods: CaseClientFactoryMethods = { + create, + get, + update, + push, + addComment, + getAlerts, + getFields, + getMappings, + getUserActions, + updateAlertsStatus, }; + + return (Object.keys(methods) as CaseClientMethods[]).reduce((client, method) => { + client[method] = methods[method](args); + return client; + }, {} as CaseClient); }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 0d7f3972e58e7e..b2a07e36b3aed7 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -6,9 +6,9 @@ */ import { omit } from 'lodash/fp'; -import { KibanaRequest } from 'kibana/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server/http'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { actionsClientMock } from '../../../actions/server/mocks'; import { AlertServiceContract, CaseConfigureService, @@ -17,17 +17,20 @@ import { ConnectorMappingsService, } from '../services'; import { CaseClient } from './types'; -import { authenticationMock } from '../routes/api/__fixtures__'; +import { authenticationMock, createActionsClient } from '../routes/api/__fixtures__'; import { createCaseClient } from '.'; -import { getActions } from '../routes/api/__mocks__/request_responses'; import type { CasesRequestHandlerContext } from '../types'; export type CaseClientMock = jest.Mocked; export const createCaseClientMock = (): CaseClientMock => ({ addComment: jest.fn(), create: jest.fn(), + get: jest.fn(), + push: jest.fn(), + getAlerts: jest.fn(), getFields: jest.fn(), getMappings: jest.fn(), + getUserActions: jest.fn(), update: jest.fn(), updateAlertsStatus: jest.fn(), }); @@ -47,10 +50,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ alertsService: jest.Mocked; }; }> => { - const actionsMock = actionsClientMock.create(); - actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); const request = {} as KibanaRequest; + const response = kibanaResponseFactory; const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); @@ -63,11 +66,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const connectorMappingsService = await connectorMappingsServicePlugin.setup(); const userActionService = { - postUserActions: jest.fn(), getUserActions: jest.fn(), + postUserActions: jest.fn(), }; - const alertsService = { initialize: jest.fn(), updateAlertsStatus: jest.fn() }; + const alertsService = { + initialize: jest.fn(), + updateAlertsStatus: jest.fn(), + getAlerts: jest.fn(), + }; const context = { core: { @@ -89,6 +96,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const caseClient = createCaseClient({ savedObjectsClient, request, + response, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index a3466e26294f8c..8778aa46a2d244 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { KibanaRequest, KibanaResponseFactory, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -16,6 +16,7 @@ import { CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, + CaseUserActionsResponse, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -25,6 +26,7 @@ import { } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; import type { CasesRequestHandlerContext } from '../types'; +import { CaseClientGetAlertsResponse } from './alerts/types'; export interface CaseClientCreate { theCase: CasePostRequest; @@ -35,6 +37,18 @@ export interface CaseClientUpdate { cases: CasesPatchRequest; } +export interface CaseClientGet { + id: string; + includeComments?: boolean; +} + +export interface CaseClientPush { + actionsClient: ActionsClient; + caseClient: CaseClient; + caseId: string; + connectorId: string; +} + export interface CaseClientAddComment { caseClient: CaseClient; caseId: string; @@ -46,11 +60,27 @@ export interface CaseClientUpdateAlertsStatus { status: CaseStatuses; } +export interface CaseClientGetAlerts { + ids: string[]; +} + +export interface CaseClientGetUserActions { + caseId: string; +} + +export interface MappingsClient { + actionsClient: ActionsClient; + caseClient: CaseClient; + connectorId: string; + connectorType: string; +} + export interface CaseClientFactoryArguments { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; request: KibanaRequest; + response: KibanaResponseFactory; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; @@ -65,15 +95,22 @@ export interface ConfigureFields { export interface CaseClient { addComment: (args: CaseClientAddComment) => Promise; create: (args: CaseClientCreate) => Promise; + get: (args: CaseClientGet) => Promise; + getAlerts: (args: CaseClientGetAlerts) => Promise; getFields: (args: ConfigureFields) => Promise; getMappings: (args: MappingsClient) => Promise; + getUserActions: (args: CaseClientGetUserActions) => Promise; + push: (args: CaseClientPush) => Promise; update: (args: CaseClientUpdate) => Promise; updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; } -export interface MappingsClient { - actionsClient: ActionsClient; - caseClient: CaseClient; - connectorId: string; - connectorType: string; -} +export type CaseClientFactoryMethod = ( + factoryArgs: CaseClientFactoryArguments +) => (methodArgs: any) => Promise; + +export type CaseClientMethods = keyof CaseClient; + +export type CaseClientFactoryMethods = { + [K in CaseClientMethods]: CaseClientFactoryMethod; +}; diff --git a/x-pack/plugins/case/server/client/user_actions/get.ts b/x-pack/plugins/case/server/client/user_actions/get.ts new file mode 100644 index 00000000000000..e83a9e34842625 --- /dev/null +++ b/x-pack/plugins/case/server/client/user_actions/get.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; +import { CaseClientGetUserActions, CaseClientFactoryArguments } from '../types'; + +export const get = ({ + savedObjectsClient, + userActionService, +}: CaseClientFactoryArguments) => async ({ + caseId, +}: CaseClientGetUserActions): Promise => { + const userActions = await userActionService.getUserActions({ + client: savedObjectsClient, + caseId, + }); + + return CaseUserActionsResponseRt.encode( + userActions.saved_objects.map((ua) => ({ + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + })) + ); +}; diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 01446942c33c6a..9907aa5b3cd3a8 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,7 +7,7 @@ import { curry } from 'lodash'; -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createCaseClient } from '../../client'; @@ -73,6 +73,7 @@ async function executor( const caseClient = createCaseClient({ savedObjectsClient, request: {} as KibanaRequest, + response: kibanaResponseFactory, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 100511e271b026..00809d81ca5f25 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -5,43 +5,14 @@ * 2.0. */ -import { Logger } from 'kibana/server'; -import { - ActionTypeConfig, - ActionTypeSecrets, - ActionTypeParams, - ActionType, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../actions/server/types'; -import { - CaseServiceSetup, - CaseConfigureServiceSetup, - CaseUserActionServiceSetup, - ConnectorMappingsServiceSetup, - AlertServiceContract, -} from '../services'; - +import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types'; import { getActionType as getCaseConnector } from './case'; +import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter'; +import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; +import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; +import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; -export interface GetActionTypeParams { - logger: Logger; - caseService: CaseServiceSetup; - caseConfigureService: CaseConfigureServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; -} - -export interface RegisterConnectorsArgs extends GetActionTypeParams { - actionsRegisterType< - Config extends ActionTypeConfig = ActionTypeConfig, - Secrets extends ActionTypeSecrets = ActionTypeSecrets, - Params extends ActionTypeParams = ActionTypeParams, - ExecutorResultData = void - >( - actionType: ActionType - ): void; -} +export * from './types'; export const registerConnectors = ({ actionsRegisterType, @@ -63,3 +34,10 @@ export const registerConnectors = ({ }) ); }; + +export const externalServiceFormatters: ExternalServiceFormatterMapper = { + '.servicenow': serviceNowITSMExternalServiceFormatter, + '.servicenow-sir': serviceNowSIRExternalServiceFormatter, + '.jira': jiraExternalServiceFormatter, + '.resilient': resilientExternalServiceFormatter, +}; diff --git a/x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts new file mode 100644 index 00000000000000..0bfaf7cdbd9e32 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseResponse } from '../../../common/api'; +import { jiraExternalServiceFormatter } from './external_service_formatter'; + +describe('Jira formatter', () => { + const theCase = { + tags: ['tag'], + connector: { fields: { priority: 'High', issueType: 'Task', parent: null } }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await jiraExternalServiceFormatter.format(theCase, []); + expect(res).toEqual({ ...theCase.connector.fields, labels: theCase.tags }); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { tags: ['tag'], connector: { fields: null } } as CaseResponse; + const res = await jiraExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ priority: null, issueType: null, parent: null, labels: theCase.tags }); + }); + + it('it replace white spaces with hyphens on tags', async () => { + const res = await jiraExternalServiceFormatter.format( + { ...theCase, tags: ['a tag with spaces'] }, + [] + ); + expect(res).toEqual({ ...theCase.connector.fields, labels: ['a-tag-with-spaces'] }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts new file mode 100644 index 00000000000000..74376d295fea5a --- /dev/null +++ b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; + +interface ExternalServiceParams extends JiraFieldsType { + labels: string[]; +} + +const format: ExternalServiceFormatter['format'] = (theCase) => { + const { priority = null, issueType = null, parent = null } = + (theCase.connector.fields as ConnectorJiraTypeFields['fields']) ?? {}; + return { + priority, + // Jira do not allows empty spaces on labels. We replace white spaces with hyphens + labels: theCase.tags.map((tag) => tag.replace(/\s+/g, '-')), + issueType, + parent, + }; +}; + +export const jiraExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.ts b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.ts new file mode 100644 index 00000000000000..01280e9692b5e5 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseResponse } from '../../../common/api'; +import { resilientExternalServiceFormatter } from './external_service_formatter'; + +describe('IBM Resilient formatter', () => { + const theCase = { + connector: { fields: { incidentTypes: ['2'], severityCode: '2' } }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await resilientExternalServiceFormatter.format(theCase, []); + expect(res).toEqual({ ...theCase.connector.fields }); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { tags: ['a tag'], connector: { fields: null } } as CaseResponse; + const res = await resilientExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ incidentTypes: null, severityCode: null }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts new file mode 100644 index 00000000000000..76554dce32797e --- /dev/null +++ b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; + +const format: ExternalServiceFormatter['format'] = (theCase) => { + const { incidentTypes = null, severityCode = null } = + (theCase.connector.fields as ConnectorResillientTypeFields['fields']) ?? {}; + return { incidentTypes, severityCode }; +}; + +export const resilientExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts new file mode 100644 index 00000000000000..60faa82a9e3fa3 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; + +const format: ExternalServiceFormatter['format'] = (theCase) => { + const { severity = null, urgency = null, impact = null } = + (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; + return { severity, urgency, impact }; +}; + +export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts new file mode 100644 index 00000000000000..033f184c7e751c --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseResponse } from '../../../common/api'; +import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter'; + +describe('ITSM formatter', () => { + const theCase = { + connector: { fields: { severity: '2', urgency: '2', impact: '2' } }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await serviceNowITSMExternalServiceFormatter.format(theCase, []); + expect(res).toEqual(theCase.connector.fields); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { connector: { fields: null } } as CaseResponse; + const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ severity: null, urgency: null, impact: null }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts new file mode 100644 index 00000000000000..4faca62c6e706f --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseResponse } from '../../../common/api'; +import { serviceNowSIRExternalServiceFormatter } from './sir_formatter'; + +describe('ITSM formatter', () => { + const theCase = { + connector: { + fields: { + destIp: true, + sourceIp: true, + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malwareHash: true, + malwareUrl: true, + priority: '2 - High', + }, + }, + } as CaseResponse; + + it('it formats correctly without alerts', async () => { + const res = await serviceNowSIRExternalServiceFormatter.format(theCase, []); + expect(res).toEqual({ + dest_ip: null, + source_ip: null, + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: null, + malware_url: null, + priority: '2 - High', + }); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { connector: { fields: null } } as CaseResponse; + const res = await serviceNowSIRExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ + dest_ip: null, + source_ip: null, + category: null, + subcategory: null, + malware_hash: null, + malware_url: null, + priority: null, + }); + }); + + it('it formats correctly with alerts', async () => { + const alerts = [ + { + id: 'alert-1', + index: 'index-1', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com' }, + }, + { + id: 'alert-2', + index: 'index-2', + destination: { ip: '192.168.1.4' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' }, + }, + url: { full: 'https://attack.com/api' }, + }, + ]; + const res = await serviceNowSIRExternalServiceFormatter.format(theCase, alerts); + expect(res).toEqual({ + dest_ip: '192.168.1.1,192.168.1.4', + source_ip: '192.168.1.2,192.168.1.3', + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', + malware_url: 'https://attack.com,https://attack.com/api', + priority: '2 - High', + }); + }); + + it('it handles duplicates correctly', async () => { + const alerts = [ + { + id: 'alert-1', + index: 'index-1', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com' }, + }, + { + id: 'alert-2', + index: 'index-2', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com/api' }, + }, + ]; + const res = await serviceNowSIRExternalServiceFormatter.format(theCase, alerts); + expect(res).toEqual({ + dest_ip: '192.168.1.1', + source_ip: '192.168.1.2,192.168.1.3', + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + malware_url: 'https://attack.com,https://attack.com/api', + priority: '2 - High', + }); + }); + + it('it formats correctly when field is not selected', async () => { + const alerts = [ + { + id: 'alert-1', + index: 'index-1', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com' }, + }, + { + id: 'alert-2', + index: 'index-2', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com/api' }, + }, + ]; + + const newCase = { + ...theCase, + connector: { fields: { ...theCase.connector.fields, destIp: false, malwareHash: false } }, + } as CaseResponse; + + const res = await serviceNowSIRExternalServiceFormatter.format(newCase, alerts); + expect(res).toEqual({ + dest_ip: null, + source_ip: '192.168.1.2,192.168.1.3', + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: null, + malware_url: 'https://attack.com,https://attack.com/api', + priority: '2 - High', + }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts new file mode 100644 index 00000000000000..d2458e6c7ae534 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { get } from 'lodash/fp'; +import { ConnectorServiceNowSIRTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; +interface ExternalServiceParams { + dest_ip: string | null; + source_ip: string | null; + category: string | null; + subcategory: string | null; + malware_hash: string | null; + malware_url: string | null; + priority: string | null; +} +type SirFieldKey = 'dest_ip' | 'source_ip' | 'malware_hash' | 'malware_url'; +type AlertFieldMappingAndValues = Record< + string, + { alertPath: string; sirFieldKey: SirFieldKey; add: boolean } +>; +const format: ExternalServiceFormatter['format'] = (theCase, alerts) => { + const { + destIp = null, + sourceIp = null, + category = null, + subcategory = null, + malwareHash = null, + malwareUrl = null, + priority = null, + } = (theCase.connector.fields as ConnectorServiceNowSIRTypeFields['fields']) ?? {}; + const alertFieldMapping: AlertFieldMappingAndValues = { + destIp: { alertPath: 'destination.ip', sirFieldKey: 'dest_ip', add: !!destIp }, + sourceIp: { alertPath: 'source.ip', sirFieldKey: 'source_ip', add: !!sourceIp }, + malwareHash: { alertPath: 'file.hash.sha256', sirFieldKey: 'malware_hash', add: !!malwareHash }, + malwareUrl: { alertPath: 'url.full', sirFieldKey: 'malware_url', add: !!malwareUrl }, + }; + + const manageDuplicate: Record> = { + dest_ip: new Set(), + source_ip: new Set(), + malware_hash: new Set(), + malware_url: new Set(), + }; + + let sirFields: Record = { + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }; + + const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter( + (key) => alertFieldMapping[key].add + ); + + if (fieldsToAdd.length > 0) { + sirFields = alerts.reduce>((acc, alert) => { + fieldsToAdd.forEach((alertField) => { + const field = get(alertFieldMapping[alertField].alertPath, alert); + if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { + manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); + acc = { + ...acc, + [alertFieldMapping[alertField].sirFieldKey]: `${ + acc[alertFieldMapping[alertField].sirFieldKey] != null + ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` + : field + }`, + }; + } + }); + return acc; + }, sirFields); + } + + return { + ...sirFields, + category, + subcategory, + priority, + }; +}; +export const serviceNowSIRExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/types.ts b/x-pack/plugins/case/server/connectors/types.ts new file mode 100644 index 00000000000000..8e7eb91ad2dc66 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from 'kibana/server'; +import { + ActionTypeConfig, + ActionTypeSecrets, + ActionTypeParams, + ActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../actions/server/types'; +import { CaseResponse, ConnectorTypes } from '../../common/api'; +import { CaseClientGetAlertsResponse } from '../client/alerts/types'; +import { + CaseServiceSetup, + CaseConfigureServiceSetup, + CaseUserActionServiceSetup, + ConnectorMappingsServiceSetup, + AlertServiceContract, +} from '../services'; + +export interface GetActionTypeParams { + logger: Logger; + caseService: CaseServiceSetup; + caseConfigureService: CaseConfigureServiceSetup; + connectorMappingsService: ConnectorMappingsServiceSetup; + userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; +} + +export interface RegisterConnectorsArgs extends GetActionTypeParams { + actionsRegisterType< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets, + Params extends ActionTypeParams = ActionTypeParams, + ExecutorResultData = void + >( + actionType: ActionType + ): void; +} + +export type FormatterConnectorTypes = Exclude; + +export interface ExternalServiceFormatter { + format: (theCase: CaseResponse, alerts: CaseClientGetAlertsResponse) => TExternalServiceParams; +} + +export type ExternalServiceFormatterMapper = { + [x in FormatterConnectorTypes]: ExternalServiceFormatter; +}; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 8b4fdc73dab44e..5d05db165f6373 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,7 +5,13 @@ * 2.0. */ -import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; +import { + IContextProvider, + KibanaRequest, + KibanaResponseFactory, + Logger, + PluginInitializerContext, +} from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -123,11 +129,13 @@ export class CasePlugin { const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, - request: KibanaRequest + request: KibanaRequest, + response: KibanaResponseFactory ) => { return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, + response, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, @@ -161,7 +169,7 @@ export class CasePlugin { userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; }): IContextProvider => { - return async (context, request) => { + return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); return { getCaseClient: () => { @@ -172,8 +180,9 @@ export class CasePlugin { connectorMappingsService, userActionService, alertsService, - request, context, + request, + response, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 8dc970d235fea8..18730effdf55a4 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -17,6 +17,7 @@ import { CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, } from '../../../saved_object_types'; export const createMockSavedObjectsRepository = ({ @@ -24,11 +25,13 @@ export const createMockSavedObjectsRepository = ({ caseCommentSavedObject = [], caseConfigureSavedObject = [], caseMappingsSavedObject = [], + caseUserActionsSavedObject = [], }: { caseSavedObject?: any[]; caseCommentSavedObject?: any[]; caseConfigureSavedObject?: any[]; caseMappingsSavedObject?: any[]; + caseUserActionsSavedObject?: any[]; } = {}) => { const mockSavedObjectsClientContract = ({ bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { @@ -57,6 +60,7 @@ export const createMockSavedObjectsRepository = ({ }), }; }), + bulkCreate: jest.fn(), bulkUpdate: jest.fn((objects: Array>) => { return { saved_objects: objects.map(({ id, type, attributes }) => { @@ -136,6 +140,16 @@ export const createMockSavedObjectsRepository = ({ saved_objects: caseCommentSavedObject, }; } + + if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { + return { + page: 1, + per_page: 5, + total: caseUserActionsSavedObject.length, + saved_objects: caseUserActionsSavedObject, + }; + } + return { page: 1, per_page: 5, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts index 5e2c29f29a3e73..1abd44aec15520 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts @@ -10,3 +10,4 @@ export { createMockSavedObjectsRepository } from './create_mock_so_repository'; export { createRouteContext } from './route_contexts'; export { authenticationMock } from './authc_mock'; export { createRoute } from './mock_router'; +export { createActionsClient } from './mock_actions_client'; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.ts new file mode 100644 index 00000000000000..d153c328cbb91b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { actionsClientMock } from '../../../../../actions/server/mocks'; +import { + getActions, + getActionTypes, + getActionExecuteResults, +} from '../__mocks__/request_responses'; + +export const createActionsClient = () => { + const actionsMock = actionsClientMock.create(); + actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + actionsMock.get.mockImplementation(({ id }) => { + const actions = getActions(); + const action = actions.find((a) => a.id === id); + if (action) { + return Promise.resolve(action); + } else { + return Promise.reject(SavedObjectsErrorHelpers.createGenericNotFoundError('action', id)); + } + }); + actionsMock.execute.mockImplementation(({ actionId }) => + Promise.resolve(getActionExecuteResults(actionId)) + ); + + return actionsMock; +}; 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 4ac5004eb3dfd8..514f77a8f953dc 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 @@ -8,6 +8,7 @@ import { SavedObject } from 'kibana/server'; import { CaseStatuses, + CaseUserActionAttributes, CommentAttributes, CommentType, ConnectorMappings, @@ -15,7 +16,10 @@ import { ESCaseAttributes, ESCasesConfigureAttributes, } from '../../../../common/api'; -import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../../saved_object_types'; +import { + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, +} from '../../../saved_object_types'; import { mappings } from '../../../client/configure/mock'; export const mockCases: Array> = [ @@ -424,3 +428,44 @@ export const mockCaseMappings: Array> = [ references: [], }, ]; + +export const mockUserActions: Array> = [ + { + type: CASE_USER_ACTION_SAVED_OBJECT, + id: 'mock-user-actions-1', + attributes: { + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + action: 'create', + action_at: '2021-02-03T17:41:03.771Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"title":"A case","tags":["case"],"description":"Yeah!","connector":{"id":"connector-od","name":"My Connector","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', + old_value: null, + }, + version: 'WzYsMV0=', + references: [], + }, + { + type: CASE_USER_ACTION_SAVED_OBJECT, + id: 'mock-user-actions-2', + attributes: { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:44:21.067Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"type":"alert","alertId":"cec3da90fb37a44407145adf1593f3b0d5ad94c4654201f773d63b5d4706128e","index":".siem-signals-default-000008"}', + old_value: null, + }, + version: 'WzYsMV0=', + references: [], + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 9f7258fc7edaf6..74665ffdc5b16a 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,24 +5,25 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; -import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; -import { actionsClientMock } from '../../../../../actions/server/mocks'; +import { KibanaRequest, kibanaResponseFactory } from '../../../../../../../src/core/server'; +import { + loggingSystemMock, + elasticsearchServiceMock, +} from '../../../../../../../src/core/server/mocks'; import { createCaseClient } from '../../../client'; import { AlertService, CaseService, CaseConfigureService, ConnectorMappingsService, + CaseUserActionService, } from '../../../services'; -import { getActions, getActionTypes } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; import type { CasesRequestHandlerContext } from '../../../types'; +import { createActionsClient } from './mock_actions_client'; export const createRouteContext = async (client: any, badAuth = false) => { - const actionsMock = actionsClientMock.create(); - actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); - actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); const esClientMock = elasticsearchServiceMock.createClusterClient(); @@ -30,11 +31,13 @@ export const createRouteContext = async (client: any, badAuth = false) => { const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); + const caseUserActionsServicePlugin = new CaseUserActionService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await caseUserActionsServicePlugin.setup(); const alertsService = new AlertService(); alertsService.initialize(esClientMock); @@ -59,16 +62,14 @@ export const createRouteContext = async (client: any, badAuth = false) => { const caseClient = createCaseClient({ savedObjectsClient: client, request: {} as KibanaRequest, + response: kibanaResponseFactory, caseService, caseConfigureService, connectorMappingsService, - userActionService: { - postUserActions: jest.fn(), - getUserActions: jest.fn(), - }, + userActionService, alertsService, context, }); - return context; + return { context, services: { userActionService } }; }; diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index f2109167527c7b..ae14b44e7dffe8 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -10,11 +10,9 @@ import { CasePostRequest, CasesConfigureRequest, ConnectorTypes, - PostPushRequest, } from '../../../../common/api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../actions/server/types'; -import { params } from '../cases/configure/mock'; export const newCase: CasePostRequest = { title: 'My new case', @@ -74,6 +72,16 @@ export const getActions = (): FindActionResult[] => [ isPreconfigured: false, referencedByCount: 0, }, + { + id: 'for-mock-case-id-3', + actionTypeId: '.jira', + name: 'For mock case id 3', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, ]; export const getActionTypes = (): ActionTypeConnector[] => [ @@ -119,6 +127,18 @@ export const getActionTypes = (): ActionTypeConnector[] => [ }, ]; +export const getActionExecuteResults = (actionId = '123') => ({ + status: 'ok' as const, + data: { + title: 'RJ2-200', + id: '10663', + pushedDate: '2020-12-17T00:32:40.738Z', + url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', + comments: [], + }, + actionId, +}); + export const newConfiguration: CasesConfigureRequest = { connector: { id: '456', @@ -129,11 +149,6 @@ export const newConfiguration: CasesConfigureRequest = { closure_type: 'close-by-pushing', }; -export const newPostPushRequest: PostPushRequest = { - params: params[ConnectorTypes.jira], - connector_type: ConnectorTypes.jira, -}; - export const executePushResponse = { status: 'ok', data: { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts index 9454f582e50c63..dcbcd7b9e246d3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts @@ -33,14 +33,14 @@ describe('DELETE comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(204); }); it(`returns an error when thrown from deleteComment service`, async () => { @@ -53,14 +53,14 @@ describe('DELETE comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts index a1f4b8c2583cf7..8ee43eaba8a827 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts @@ -34,14 +34,14 @@ describe('GET comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); const myPayload = mockCaseComments.find((s) => s.id === 'mock-comment-1'); expect(myPayload).not.toBeUndefined(); @@ -59,13 +59,13 @@ describe('GET comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 3bd8a688e1bba3..33dc24d776c701 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -41,14 +41,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual( 'Update my comment' @@ -71,14 +71,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( 'new-id' @@ -102,14 +102,14 @@ describe('PATCH comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -130,14 +130,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -161,14 +161,14 @@ describe('PATCH comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -190,14 +190,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -219,14 +219,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); expect(response.payload.message).toEqual('You cannot change the type of the comment.'); @@ -247,14 +247,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); @@ -273,14 +273,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 54699415cd9849..0ab038a62ac771 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -43,14 +43,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( 'mock-comment' @@ -71,14 +71,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( 'mock-comment' @@ -95,14 +95,14 @@ describe('POST comment', () => { body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); @@ -124,14 +124,14 @@ describe('POST comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -152,14 +152,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -183,14 +183,14 @@ describe('POST comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -212,14 +212,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -238,14 +238,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); @@ -262,14 +262,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); @@ -289,7 +289,7 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, @@ -297,7 +297,7 @@ describe('POST comment', () => { true ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({ comment: 'Wow, good luck catching that bad meanie!', diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts index ddcbb3522f9868..ff4216a05ae58c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts @@ -34,7 +34,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -57,7 +57,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }], caseMappingsSavedObject: mockCaseMappings, @@ -98,7 +98,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [], }) @@ -116,7 +116,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], }) @@ -133,7 +133,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: [], 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 0f74b7291dd81d..17972e129a8257 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 @@ -33,9 +33,9 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps throw Boom.badRequest('RouteHandlerContext is not registered for cases'); } const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); + throw Boom.notFound('Action client not found'); } try { mappings = await caseClient.getMappings({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index 1e37918d7766a2..3fa0fe2f83f79a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -32,7 +32,7 @@ describe('GET connectors', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -54,7 +54,7 @@ describe('GET connectors', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -106,6 +106,16 @@ describe('GET connectors', () => { isPreconfigured: false, referencedByCount: 0, }, + { + id: 'for-mock-case-id-3', + actionTypeId: '.jira', + name: 'For mock case id 3', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, ]); }); @@ -115,7 +125,7 @@ describe('GET connectors', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index fb0595f858d4e5..0a368e0276bb5c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -14,18 +14,15 @@ import { FindActionResult } from '../../../../../../actions/server/types'; import { CASE_CONFIGURE_CONNECTORS_URL, - SERVICENOW_ACTION_TYPE_ID, - JIRA_ACTION_TYPE_ID, - RESILIENT_ACTION_TYPE_ID, + SUPPORTED_CONNECTORS, } from '../../../../../common/constants'; const isConnectorSupported = ( action: FindActionResult, actionTypes: Record ): boolean => - [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( - action.actionTypeId - ) && actionTypes[action.actionTypeId]?.enabledInLicense; + SUPPORTED_CONNECTORS.includes(action.actionTypeId) && + actionTypes[action.actionTypeId]?.enabledInLicense; /* * Be aware that this api will only return 20 connectors @@ -39,10 +36,10 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { }, async (context, request, response) => { try { - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); + throw Boom.notFound('Action client not found'); } const actionTypes = (await actionsClient.listTypes()).reduce( diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts b/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts deleted file mode 100644 index 9959a3e4acee61..00000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - ServiceConnectorCaseParams, - ServiceConnectorCommentParams, - ConnectorMappingsAttributes, - ConnectorTypes, -} from '../../../../../common/api/connectors'; -export const updateUser = { - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'another' }, -}; -const entity = { - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, -}; -export const comment: ServiceConnectorCommentParams = { - comment: 'first comment', - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - ...entity, -}; -export const defaultPipes = ['informationCreated']; -const basicParams = { - comments: [comment], - description: 'a description', - title: 'a title', - savedObjectId: '1231231231232', - externalId: null, -}; -export const params = { - [ConnectorTypes.jira]: { - ...basicParams, - issueType: '10003', - priority: 'Highest', - parent: '5002', - ...entity, - } as ServiceConnectorCaseParams, - [ConnectorTypes.resilient]: { - ...basicParams, - incidentTypes: ['10003'], - severityCode: '1', - ...entity, - } as ServiceConnectorCaseParams, - [ConnectorTypes.servicenow]: { - ...basicParams, - impact: '3', - severity: '1', - urgency: '2', - ...entity, - } as ServiceConnectorCaseParams, - [ConnectorTypes.none]: {}, -}; -export const mappings: ConnectorMappingsAttributes[] = [ - { - source: 'title', - target: 'short_description', - action_type: 'overwrite', - }, - { - source: 'description', - target: 'description', - action_type: 'append', - }, - { - source: 'comments', - target: 'comments', - action_type: 'append', - }, -]; diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts index c67a1c064a82f7..f43f561e30e106 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts @@ -42,7 +42,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -76,7 +76,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -115,7 +115,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -153,7 +153,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: [], @@ -193,7 +193,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [], }) @@ -215,7 +215,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -243,7 +243,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index f847c4f776bf03..6925f116136b34 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -66,7 +66,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout throw Boom.badRequest('RouteHandlerContext is not registered for cases'); } const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { throw Boom.notFound('Action client have not been found'); } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts index 0a7f3ef488fce6..7dcb7d1fa12ca9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts @@ -40,7 +40,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -73,7 +73,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: [], @@ -113,7 +113,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -154,7 +154,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -180,7 +180,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -206,7 +206,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -232,7 +232,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -258,7 +258,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -282,7 +282,7 @@ describe('POST configuration', () => { caseMappingsSavedObject: mockCaseMappings, }); - const context = await createRouteContext(savedObjectRepository); + const { context } = await createRouteContext(savedObjectRepository); const res = await routeHandler(context, req, kibanaResponseFactory); @@ -302,7 +302,7 @@ describe('POST configuration', () => { caseMappingsSavedObject: mockCaseMappings, }); - const context = await createRouteContext(savedObjectRepository); + const { context } = await createRouteContext(savedObjectRepository); const res = await routeHandler(context, req, kibanaResponseFactory); @@ -325,7 +325,7 @@ describe('POST configuration', () => { caseMappingsSavedObject: mockCaseMappings, }); - const context = await createRouteContext(savedObjectRepository); + const { context } = await createRouteContext(savedObjectRepository); const res = await routeHandler(context, req, kibanaResponseFactory); @@ -341,7 +341,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], }) @@ -359,7 +359,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-delete' }], }) @@ -384,7 +384,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -411,7 +411,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -437,7 +437,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -459,7 +459,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index 8e5fd95facc3d1..0bcf2ac18740fc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -39,9 +39,9 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route throw Boom.badRequest('RouteHandlerContext is not registered for cases'); } const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); + throw Boom.notFound('Action client not found'); } const client = context.core.savedObjects.client; const query = pipe( diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts deleted file mode 100644 index e382813dbf0c5a..00000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseMappings, -} from '../../__fixtures__'; - -import { initPostPushToService } from './post_push_to_service'; -import { executePushResponse, newPostPushRequest } from '../../__mocks__/request_responses'; -import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants'; -import type { CasesRequestHandlerContext } from '../../../../types'; - -describe('Post push to service', () => { - let routeHandler: RequestHandler; - const req = httpServerMock.createKibanaRequest({ - path: `${CASE_CONFIGURE_PUSH_URL}`, - method: 'post', - params: { - connector_id: '666', - }, - body: newPostPushRequest, - }); - let context: CasesRequestHandlerContext; - beforeAll(async () => { - routeHandler = await createRoute(initPostPushToService, 'post'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'), - })); - context = await createRouteContext( - createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappings, - }) - ); - }); - - it('Happy path - posts success', async () => { - const betterContext = ({ - ...context, - actions: { - ...context.actions, - getActionsClient: () => { - const actions = context!.actions!.getActionsClient(); - return { - ...actions, - execute: jest.fn().mockImplementation(({ actionId }) => { - return { - status: 'ok', - data: { - title: 'RJ2-200', - id: '10663', - pushedDate: '2020-12-17T00:32:40.738Z', - url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', - comments: [], - }, - actionId, - }; - }), - }; - }, - }, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual({ - ...executePushResponse, - actionId: '666', - }); - }); - it('Unhappy path - context case missing', async () => { - const betterContext = ({ - ...context, - case: null, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toBeTruthy(); - expect(res.payload.output.payload.message).toEqual( - 'RouteHandlerContext is not registered for cases' - ); - }); - it('Unhappy path - context actions missing', async () => { - const betterContext = ({ - ...context, - actions: null, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, req, kibanaResponseFactory); - expect(res.status).toEqual(404); - expect(res.payload.isBoom).toBeTruthy(); - expect(res.payload.output.payload.message).toEqual('Action client have not been found'); - }); -}); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts deleted file mode 100644 index b8ba1a9ccb6ef0..00000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import Boom from '@hapi/boom'; -import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError } from '../../utils'; - -import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants'; -import { - ConnectorRequestParamsRt, - PostPushRequestRt, - throwErrors, -} from '../../../../../common/api'; -import { mapIncident } from './utils'; - -export function initPostPushToService({ router }: RouteDeps) { - router.post( - { - path: CASE_CONFIGURE_PUSH_URL, - validate: { - params: escapeHatch, - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - if (!context.case) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); - } - const params = pipe( - ConnectorRequestParamsRt.decode(request.params), - fold(throwErrors(Boom.badRequest), identity) - ); - const body = pipe( - PostPushRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - const myConnectorMappings = await caseClient.getMappings({ - actionsClient, - caseClient, - connectorId: params.connector_id, - connectorType: body.connector_type, - }); - - const res = await mapIncident( - actionsClient, - params.connector_id, - body.connector_type, - myConnectorMappings, - body.params - ); - const pushRes = await actionsClient.execute({ - actionId: params.connector_id, - params: { - subAction: 'pushToService', - subActionParams: res, - }, - }); - - return response.ok({ - body: pushRes, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index 84e452ea8e8717..d588950bec9aa3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -33,14 +33,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(204); }); it(`returns an error when thrown from deleteCase service`, async () => { @@ -52,14 +52,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); it(`returns an error when thrown from getAllCaseComments service`, async () => { @@ -71,14 +71,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCasesErrorTriggerData, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); it(`returns an error when thrown from deleteComment service`, async () => { @@ -90,14 +90,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCasesErrorTriggerData, caseCommentSavedObject: mockCasesErrorTriggerData, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); }); 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 acd7de1e8643e9..ca9f731ca50107 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 @@ -30,13 +30,13 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); // mockSavedObjectsRepository do not support filters and returns all cases every time. @@ -51,13 +51,13 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases[2].connector.id).toEqual('123'); }); @@ -68,13 +68,13 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases[0].connector.id).toEqual('none'); }); @@ -85,14 +85,14 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases[0].connector.id).toEqual('none'); }); 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 7aa6f110a00791..968dd0424fe3f0 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 @@ -40,13 +40,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); const savedObject = (mockCases.find( (s) => s.id === 'mock-id-1' ) as unknown) as SavedObject; @@ -71,13 +71,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); @@ -95,14 +95,14 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments).toHaveLength(5); @@ -120,13 +120,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCasesErrorTriggerData, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); @@ -143,13 +143,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ @@ -172,14 +172,14 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ @@ -202,14 +202,14 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ 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 f563fc274b18bd..55377d93e528d0 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 @@ -7,9 +7,8 @@ import { schema } from '@kbn/config-schema'; -import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; -import { flattenCaseSavedObject, wrapError } from '../utils'; +import { wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { @@ -26,44 +25,17 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }, }, async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const includeComments = JSON.parse(request.query.includeComments); - - const [theCase] = await Promise.all([ - caseService.getCase({ - client, - caseId: request.params.case_id, - }), - ]); - - if (!includeComments) { - return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: theCase, - }) - ), - }); - } + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const theComments = await caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - options: { - sortField: 'created_at', - sortOrder: 'asc', - }, - }); + const caseClient = context.case.getCaseClient(); + const includeComments = JSON.parse(request.query.includeComments); + const id = request.params.case_id; + try { return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: theCase, - comments: theComments.saved_objects, - totalComment: theComments.total, - }) - ), + body: await caseClient.get({ id, includeComments }), }); } catch (error) { return response.customError(wrapError(error)); 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 95f7e5bb19a011..6d1134b15b65e3 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 @@ -44,13 +44,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual([ { @@ -97,14 +97,14 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual([ { @@ -151,13 +151,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual([ { @@ -204,13 +204,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload[0].connector.id).toEqual('none'); }); @@ -230,13 +230,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload[0].connector.id).toEqual('123'); }); @@ -261,13 +261,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload[0].connector).toEqual({ id: '456', @@ -292,13 +292,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); @@ -317,14 +317,14 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(406); }); @@ -343,13 +343,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); 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 997516d2e30b64..292e2c6775a801 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 @@ -49,13 +49,13 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); expect(response.payload.status).toEqual('open'); @@ -88,14 +88,14 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ id: '123', @@ -121,13 +121,13 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); @@ -146,13 +146,13 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); @@ -179,7 +179,7 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, @@ -187,7 +187,7 @@ describe('POST cases', () => { true ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual({ closed_at: null, diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index 549195966b2a7d..49801ea4e2f3eb 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -13,63 +13,187 @@ import { createRoute, createRouteContext, mockCases, + mockCaseConfigure, + mockCaseMappings, + mockUserActions, + mockCaseComments, } from '../__fixtures__'; -import { initPushCaseUserActionApi } from './push_case'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; -import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; +import { initPushCaseApi } from './push_case'; +import { CasesRequestHandlerContext } from '../../../types'; +import { getCasePushUrl } from '../../../../common/api/helpers'; describe('Push case', () => { let routeHandler: RequestHandler; const mockDate = '2019-11-25T21:54:48.952Z'; - const caseExternalServiceRequestBody = { - connector_id: 'connector_id', - connector_name: 'connector_name', - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }; + const caseId = 'mock-id-3'; + const connectorId = '123'; + const path = getCasePushUrl(caseId, connectorId); + beforeAll(async () => { - routeHandler = await createRoute(initPushCaseUserActionApi, 'post'); + routeHandler = await createRoute(initPushCaseApi, 'post'); const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; spyOnDate.mockImplementation(() => ({ toISOString: jest.fn().mockReturnValue(mockDate), })); }); + it(`Pushes a case`, async () => { const request = httpServerMock.createKibanaRequest({ - path: `${CASE_DETAILS_URL}/_push`, + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.external_service).toEqual({ + connector_id: connectorId, + connector_name: 'ServiceNow', + external_id: '10663', + external_title: 'RJ2-200', + external_url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', + pushed_at: mockDate, + pushed_by: { + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }, + }); + }); + + it(`Pushes a case with comments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + caseCommentSavedObject: [mockCaseComments[0]], + }) + ); + + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.comments[0].pushed_at).toEqual(mockDate); + expect(response.payload.comments[0].pushed_by).toEqual({ + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }); + }); + + it(`Filters comments with type alert correctly`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, method: 'post', params: { - case_id: 'mock-id-3', + case_id: caseId, + connector_id: connectorId, }, - body: caseExternalServiceRequestBody, + body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + caseCommentSavedObject: [mockCaseComments[0], mockCaseComments[3]], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const caseClient = context.case.getCaseClient(); + caseClient.getAlerts = jest.fn().mockResolvedValue([]); + + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.external_service.pushed_at).toEqual(mockDate); - expect(response.payload.external_service.connector_id).toEqual('connector_id'); - expect(response.payload.closed_at).toEqual(null); + expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] }); + }); + + it(`Calls execute with correct arguments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: 'for-mock-case-id-3', + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + + await routeHandler(context, request, kibanaResponseFactory); + expect(actionsClient.execute).toHaveBeenCalledWith({ + actionId: 'for-mock-case-id-3', + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + issueType: 'Task', + parent: null, + priority: 'High', + labels: ['LOLBins'], + summary: 'Another bad one (created at 2019-11-25T22:32:17.947Z by elastic)', + description: + 'Oh no, a bad meanie going LOLBins all over the place! (created at 2019-11-25T22:32:17.947Z by elastic)', + externalId: null, + }, + comments: [], + }, + }, + }); }); + it(`Pushes a case and closes when closure_type: 'close-by-pushing'`, async () => { const request = httpServerMock.createKibanaRequest({ - path: `${CASE_DETAILS_URL}/_push`, + path, method: 'post', params: { - case_id: 'mock-id-3', + case_id: caseId, + connector_id: connectorId, }, - body: caseExternalServiceRequestBody, + body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseUserActionsSavedObject: mockUserActions, caseConfigureSavedObject: [ { ...mockCaseConfigure[0], @@ -82,30 +206,259 @@ describe('Push case', () => { }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.external_service.pushed_at).toEqual(mockDate); - expect(response.payload.external_service.connector_id).toEqual('connector_id'); expect(response.payload.closed_at).toEqual(mockDate); }); - it(`Returns an error if pushCaseUserAction throws`, async () => { + it(`post the correct user action`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context, services } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + services.userActionService.postUserActions = jest.fn(); + const postUserActions = services.userActionService.postUserActions as jest.Mock; + + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(postUserActions.mock.calls[0][0].actions[0].attributes).toEqual({ + action: 'push-to-service', + action_at: '2019-11-25T21:54:48.952Z', + action_by: { + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }, + action_field: ['pushed'], + new_value: + '{"pushed_at":"2019-11-25T21:54:48.952Z","pushed_by":{"username":"awesome","full_name":"Awesome D00d","email":"d00d@awesome.com"},"connector_id":"123","connector_name":"ServiceNow","external_id":"10663","external_title":"RJ2-200","external_url":"https://siem-kibana.atlassian.net/browse/RJ2-200"}', + old_value: null, + }); + }); + + it('Unhappy path - case id is missing', async () => { const request = httpServerMock.createKibanaRequest({ - path: `${CASE_DETAILS_URL}/_push`, + path, method: 'post', - body: { - notagoodbody: 'Throw an error', + params: { + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + }); + + it('Unhappy path - connector id is missing', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, }, + body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + }); + + it('Unhappy path - case does not exists', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: 'not-exist', + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(404); + }); + + it('Unhappy path - connector does not exists', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: 'not-exists', + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(404); + }); + + it('Unhappy path - cannot push to a closed case', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: 'mock-id-4', + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(409); + expect(res.payload.output.payload.message).toBe( + 'This case Another bad one is closed. You can not pushed if the case is closed.' + ); + }); + + it('Unhappy path - throws when external service returns an error', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + (actionsClient.execute as jest.Mock).mockResolvedValue({ + status: 'error', + }); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(424); + expect(res.payload.output.payload.message).toBe('Error pushing to service'); + }); + + it('Unhappy path - context case missing', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const betterContext = ({ + ...context, + case: null, + } as unknown) as CasesRequestHandlerContext; + + const res = await routeHandler(betterContext, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload).toEqual('RouteHandlerContext is not registered for cases'); + }); + + it('Unhappy path - context actions missing', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const betterContext = ({ + ...context, + actions: null, + } as unknown) as CasesRequestHandlerContext; + + const res = await routeHandler(betterContext, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload).toEqual('Action client not found'); }); }); 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 218b1f16b9aab6..6d670c38bbf85c 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 @@ -5,204 +5,51 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import isEmpty from 'lodash/isEmpty'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - flattenCaseSavedObject, - wrapError, - escapeHatch, - getCommentContextFromAttributes, -} from '../utils'; +import { wrapError, escapeHatch } from '../utils'; -import { - CaseExternalServiceRequestRt, - CaseResponseRt, - throwErrors, - CaseStatuses, -} from '../../../../common/api'; -import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; +import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; import { RouteDeps } from '../types'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { CASE_PUSH_URL } from '../../../../common/constants'; -export function initPushCaseUserActionApi({ - caseConfigureService, - caseService, - router, - userActionService, -}: RouteDeps) { +export function initPushCaseApi({ router }: RouteDeps) { router.post( { - path: `${CASE_DETAILS_URL}/_push`, + path: CASE_PUSH_URL, validate: { - params: schema.object({ - case_id: schema.string(), - }), + params: escapeHatch, body: escapeHatch, }, }, async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const actionsClient = await context.actions?.getActionsClient(); - - const caseId = request.params.case_id; - const query = pipe( - CaseExternalServiceRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); - - const pushedDate = new Date().toISOString(); - - const [myCase, myCaseConfigure, totalCommentsFindByCases, connectors] = await Promise.all([ - caseService.getCase({ - client, - caseId: request.params.case_id, - }), - caseConfigureService.find({ client }), - caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }), - actionsClient.getAll(), - ]); - - if (myCase.attributes.status === CaseStatuses.closed) { - throw Boom.conflict( - `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` - ); - } - - const comments = await caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }); - - const externalService = { - pushed_at: pushedDate, - pushed_by: { username, full_name, email }, - ...query, - }; + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const updateConnector = myCase.attributes.connector; + const caseClient = context.case.getCaseClient(); + const actionsClient = context.actions?.getActionsClient(); - if ( - isEmpty(updateConnector) || - (updateConnector != null && updateConnector.id === 'none') || - !connectors.some((connector) => connector.id === updateConnector.id) - ) { - throw Boom.notFound('Connector not found or set to none'); - } + if (actionsClient == null) { + return response.badRequest({ body: 'Action client not found' }); + } - const [updatedCase, updatedComments] = await Promise.all([ - caseService.patchCase({ - client, - caseId, - updatedAttributes: { - ...(myCaseConfigure.total > 0 && - myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' - ? { - status: CaseStatuses.closed, - closed_at: pushedDate, - closed_by: { email, full_name, username }, - } - : {}), - external_service: externalService, - updated_at: pushedDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - caseService.patchComments({ - client, - comments: comments.saved_objects - .filter((comment) => comment.attributes.pushed_at == null) - .map((comment) => ({ - commentId: comment.id, - updatedAttributes: { - pushed_at: pushedDate, - pushed_by: { username, full_name, email }, - }, - version: comment.version, - })), - }), - userActionService.postUserActions({ - client, - actions: [ - ...(myCaseConfigure.total > 0 && - myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' - ? [ - buildCaseUserActionItem({ - action: 'update', - actionAt: pushedDate, - actionBy: { username, full_name, email }, - caseId, - fields: ['status'], - newValue: CaseStatuses.closed, - oldValue: myCase.attributes.status, - }), - ] - : []), - buildCaseUserActionItem({ - action: 'push-to-service', - actionAt: pushedDate, - actionBy: { username, full_name, email }, - caseId, - fields: ['pushed'], - newValue: JSON.stringify(externalService), - }), - ], - }), - ]); + try { + const params = pipe( + CasePushRequestParamsRt.decode(request.params), + fold(throwErrors(Boom.badRequest), identity) + ); return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - }, - comments: comments.saved_objects.map((origComment) => { - const updatedComment = updatedComments.saved_objects.find( - (c) => c.id === origComment.id - ); - return { - ...origComment, - ...updatedComment, - attributes: { - ...origComment.attributes, - ...updatedComment?.attributes, - ...getCommentContextFromAttributes(origComment.attributes), - }, - version: updatedComment?.version ?? origComment.version, - references: origComment?.references ?? [], - }; - }), - }) - ), + body: await caseClient.push({ + caseClient, + actionsClient, + caseId: params.case_id, + connectorId: params.connector_id, + }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index e8761ad69dccae..9644162629f24b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -36,24 +36,24 @@ describe('GET status', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, filter: 'cases.attributes.status: open', }); - expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { + expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, filter: 'cases.attributes.status: in-progress', }); - expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { + expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, filter: 'cases.attributes.status: closed', }); @@ -71,13 +71,13 @@ describe('GET status', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [{ ...mockCases[0], id: 'throw-error-find' }], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index 346eec3dde752a..06e929cc40e6bb 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -7,13 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { CaseUserActionsResponseRt } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; -export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) { +export function initGetAllUserActionsApi({ router }: RouteDeps) { router.get( { path: CASE_USER_ACTIONS_URL, @@ -24,22 +22,16 @@ export function initGetAllUserActionsApi({ userActionService, router }: RouteDep }, }, async (context, request, response) => { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; + try { - const client = context.core.savedObjects.client; - const userActions = await userActionService.getUserActions({ - client, - caseId: request.params.case_id, - }); return response.ok({ - body: CaseUserActionsResponseRt.encode( - userActions.saved_objects.map((ua) => ({ - ...ua.attributes, - action_id: ua.id, - case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', - comment_id: - ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, - })) - ), + body: await caseClient.getUserActions({ caseId }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index c399364ea35ecb..00660e08bbd83a 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -10,7 +10,7 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; -import { initPushCaseUserActionApi } from './cases/push_case'; +import { initPushCaseApi } from './cases/push_case'; import { initGetReportersApi } from './cases/reporters/get_reporters'; import { initGetCasesStatusApi } from './cases/status/get_status'; import { initGetTagsApi } from './cases/tags/get_tags'; @@ -28,7 +28,6 @@ import { initCaseConfigureGetActionConnector } from './cases/configure/get_conne import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; -import { initPostPushToService } from './cases/configure/post_push_to_service'; import { RouteDeps } from './types'; @@ -39,7 +38,7 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); - initPushCaseUserActionApi(deps); + initPushCaseApi(deps); initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); @@ -54,7 +53,6 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseConfigure(deps); initPatchCaseConfigure(deps); initPostCaseConfigure(deps); - initPostPushToService(deps); // Reporters initGetReportersApi(deps); // Status diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index b7e556daffbd99..e2751c05d880ac 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -191,11 +191,11 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { +export const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { return context.type === CommentType.user; }; -const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { +export const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { return context.type === CommentType.alert; }; @@ -206,17 +206,3 @@ export const decodeComment = (comment: CommentRequest) => { pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; - -export const getCommentContextFromAttributes = ( - attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType => - isUserContext(attributes) - ? { - type: CommentType.user, - comment: attributes.comment, - } - : { - type: CommentType.alert, - alertId: attributes.alertId, - index: attributes.index, - }; diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 4f0d415f23b505..2776d6b40761e1 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -19,6 +19,24 @@ interface UpdateAlertsStatusArgs { index: string; } +interface GetAlertsArgs { + request: KibanaRequest; + ids: string[]; + index: string; +} + +interface Alert { + _id: string; + _index: string; + _source: Record; +} + +interface AlertsResponse { + hits: { + hits: Alert[]; + }; +} + export class AlertService { private isInitialized = false; private esClient?: IClusterClient; @@ -55,4 +73,30 @@ export class AlertService { return result; } + + public async getAlerts({ request, ids, index }: GetAlertsArgs): Promise { + if (!this.isInitialized) { + throw new Error('AlertService not initialized'); + } + + // The above check makes sure that esClient is defined. + const result = await this.esClient!.asScoped(request).asCurrentUser.search({ + index, + body: { + query: { + bool: { + filter: { + bool: { + should: ids.map((_id) => ({ match: { _id } })), + minimum_should_match: 1, + }, + }, + }, + }, + }, + ignore_unavailable: true, + }); + + return result.body; + } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 7c8b44b297362d..0b3615793ef85e 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -59,4 +59,5 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ export const createAlertServiceMock = (): AlertServiceMock => ({ initialize: jest.fn(), updateAlertsStatus: jest.fn(), + getAlerts: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts index 1380cfd9fca98d..95b555c2acae6d 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts @@ -31,6 +31,13 @@ describe('Cases connector incident fields', () => { beforeEach(() => { cleanKibana(); cy.intercept('GET', '/api/cases/configure/connectors/_find', mockConnectorsResponse); + cy.intercept('POST', `/api/actions/action/${connectorIds.sn}/_execute`, (req) => { + const response = + req.body.params.subAction === 'getChoices' + ? executeResponses.servicenow.choices + : { status: 'ok', data: [] }; + req.reply(response); + }); cy.intercept('POST', `/api/actions/action/${connectorIds.jira}/_execute`, (req) => { const response = req.body.params.subAction === 'issueTypes' diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index b6c73cd37140ce..7a3ce2cb00dfa3 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -113,6 +113,77 @@ export const mockConnectorsResponse = [ }, ]; export const executeResponses = { + servicenow: { + choices: { + status: 'ok', + data: [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound or outbound', + value: '12', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Single or distributed (DoS or DDoS)', + value: '26', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound DDos', + value: 'inbound_ddos', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact', 'priority'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), + ], + }, + }, jira: { issueTypes: { status: 'ok', diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index 9ca7a99f9df164..ef8f45b222dd07 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -30,9 +30,9 @@ export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]'; -export const CONNECTOR_CARD_DETAILS = '[data-test-subj="settings-connector-card"]'; +export const CONNECTOR_CARD_DETAILS = '[data-test-subj="connector-card"]'; -export const CONNECTOR_TITLE = '[data-test-subj="settings-connector-card"] span.euiTitle'; +export const CONNECTOR_TITLE = '[data-test-subj="connector-card"] span.euiTitle'; export const DELETE_CASE_BTN = '[data-test-subj="property-actions-trash"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts b/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts index b25b8c11ff8300..5b353983e5a920 100644 --- a/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts +++ b/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts @@ -7,7 +7,7 @@ import { connectorIds } from '../objects/case'; -export const CONNECTOR_RESILIENT = `[data-test-subj="connector-settings-resilient"]`; +export const CONNECTOR_RESILIENT = `[data-test-subj="connector-fields-resilient"]`; export const CONNECTOR_SELECTOR = '[data-test-subj="dropdown-connectors"]'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 511bc682e55042..e74b66eeeb9f07 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -107,7 +107,7 @@ describe('CaseView ', () => { const fetchCaseUserActions = jest.fn(); const fetchCase = jest.fn(); const updateCase = jest.fn(); - const postPushToService = jest.fn(); + const pushCaseToExternalService = jest.fn(); const data = caseProps.caseData; const defaultGetCase = { @@ -144,7 +144,10 @@ describe('CaseView ', () => { jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); - usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService })); + usePostPushToServiceMock.mockImplementation(() => ({ + isLoading: false, + pushCaseToExternalService, + })); useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); useQueryAlertsMock.mockImplementation(() => ({ loading: false, @@ -378,7 +381,7 @@ describe('CaseView ', () => { wrapper.update(); - expect(postPushToService).toHaveBeenCalled(); + expect(pushCaseToExternalService).toHaveBeenCalled(); }); }); @@ -508,7 +511,7 @@ describe('CaseView ', () => { connector: { id: 'servicenow-1', name: 'SN 1', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, }} @@ -556,7 +559,7 @@ describe('CaseView ', () => { connector: { id: 'servicenow-1', name: 'SN 1', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, }} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 2f39a5a2951b23..e690a01dca54b7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -297,7 +297,6 @@ export const CaseComponent = React.memo( updateCase: handleUpdateCase, userCanCrud, isValidConnector: isLoadingConnectors ? true : isValidConnector, - alerts, }); const onSubmitConnector = useCallback( @@ -397,7 +396,6 @@ export const CaseComponent = React.memo( ); } }, [dispatch]); - return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx index ef0c7cfcfa2d67..371ff3528f4f05 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx @@ -72,7 +72,7 @@ describe('Connectors', () => { const newWrapper = mount( , { wrappingComponent: TestProviders, @@ -99,7 +99,7 @@ describe('Connectors', () => { const newWrapper = mount( , { wrappingComponent: TestProviders, diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 23cefce1bacd24..8e317d57dd9aca 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -186,14 +186,14 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, currentConfiguration: { connector: { id: 'servicenow-1', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-user', @@ -271,7 +271,7 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-user', @@ -331,7 +331,7 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'SN', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, persistLoading: true, @@ -450,7 +450,7 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, })) @@ -493,7 +493,7 @@ describe('closure options', () => { connector: { id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, currentConfiguration: { @@ -522,7 +522,7 @@ describe('closure options', () => { connector: { id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-pushing', @@ -546,7 +546,7 @@ describe('user interactions', () => { connector: { id: 'resilient-2', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-user', diff --git a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx index 0aaac9c30feb9c..d5f5530acde9b3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; import { EuiFormRow } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; -import { ActionConnector } from '../../../../../case/common/api/cases'; +import { ActionConnector } from '../../../../../case/common/api'; interface ConnectorSelectorProps { connectors: ActionConnector[]; @@ -21,6 +21,7 @@ interface ConnectorSelectorProps { idAria: string; isEdit: boolean; isLoading: boolean; + handleChange?: (newValue: string) => void; } export const ConnectorSelector = ({ connectors, @@ -30,8 +31,19 @@ export const ConnectorSelector = ({ idAria, isEdit = true, isLoading = false, + handleChange, }: ConnectorSelectorProps) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const onChange = useCallback( + (val: string) => { + if (handleChange) { + handleChange(val); + } + field.setValue(val); + }, + [handleChange, field] + ); + return isEdit ? ( diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/settings/card.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx index 36679cd2452bd4..03f909948370df 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx @@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react'; import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; -import { connectorsConfiguration } from '../connectors'; +import { connectorsConfiguration } from '.'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; interface ConnectorCardProps { @@ -51,10 +51,10 @@ const ConnectorCardDisplay: React.FC = ({ ); return ( <> - {isLoading && } + {isLoading && } {!isLoading && ( ({ config: { errors: {} }, secrets: { errors: {} } }), validateParams, actionConnectorFields: null, - actionParamsFields: lazy(() => import('./fields')), + actionParamsFields: lazy(() => import('./alert_fields')), }; } diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts index 7be49720fc075c..1d12d4b98a8238 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts @@ -5,17 +5,35 @@ * 2.0. */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ - import { - ServiceNowITSMConnectorConfiguration, - JiraConnectorConfiguration, - ResilientConnectorConfiguration, + getResilientActionType, + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getJiraActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; import { ConnectorConfiguration } from './types'; +const resilient = getResilientActionType(); +const serviceNowITSM = getServiceNowITSMActionType(); +const serviceNowSIR = getServiceNowSIRActionType(); +const jira = getJiraActionType(); + export const connectorsConfiguration: Record = { - '.servicenow': ServiceNowITSMConnectorConfiguration as ConnectorConfiguration, - '.jira': JiraConnectorConfiguration as ConnectorConfiguration, - '.resilient': ResilientConnectorConfiguration as ConnectorConfiguration, + '.servicenow': { + name: serviceNowITSM.actionTypeTitle ?? '', + logo: serviceNowITSM.iconClass, + }, + '.servicenow-sir': { + name: serviceNowSIR.actionTypeTitle ?? '', + logo: serviceNowSIR.iconClass, + }, + '.jira': { + name: jira.actionTypeTitle ?? '', + logo: jira.iconClass, + }, + '.resilient': { + name: resilient.actionTypeTitle ?? '', + logo: resilient.iconClass, + }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts new file mode 100644 index 00000000000000..d6896a8ac8c809 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { CaseConnector, CaseConnectorsRegistry } from './types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => { + const connectors: Map> = new Map(); + + const registry: CaseConnectorsRegistry = { + has: (id: string) => connectors.has(id), + register: (connector: CaseConnector) => { + if (connectors.has(connector.id)) { + throw new Error( + i18n.translate( + 'xpack.securitySolution.caseConnectorsRegistry.register.duplicateCaseConnectorErrorMessage', + { + defaultMessage: 'Object type "{id}" is already registered.', + values: { + id: connector.id, + }, + } + ) + ); + } + + connectors.set(connector.id, connector); + }, + get: (id: string): CaseConnector => { + if (!connectors.has(id)) { + throw new Error( + i18n.translate( + 'xpack.securitySolution.caseConnectorsRegistry.get.missingCaseConnectorErrorMessage', + { + defaultMessage: 'Object type "{id}" is not registered.', + values: { + id, + }, + } + ) + ); + } + return connectors.get(id)!; + }, + list: () => { + return Array.from(connectors).map(([id, connector]) => connector); + }, + }; + + return registry; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx similarity index 64% rename from x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx index 6b1a0cac8d9cda..41ed99e0f67686 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx @@ -8,24 +8,22 @@ import React, { memo, Suspense } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { CaseSettingsConnector, SettingFieldsProps } from './types'; -import { getCaseSettings } from '.'; +import { CaseActionConnector, ConnectorFieldsProps } from './types'; +import { getCaseConnectors } from '.'; import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; -interface Props extends Omit, 'connector'> { - connector: CaseSettingsConnector | null; +interface Props extends Omit, 'connector'> { + connector: CaseActionConnector | null; } -const SettingFieldsFormComponent: React.FC = ({ connector, isEdit, onChange, fields }) => { - const { caseSettingsRegistry } = getCaseSettings(); +const ConnectorFieldsFormComponent: React.FC = ({ connector, isEdit, onChange, fields }) => { + const { caseConnectorsRegistry } = getCaseConnectors(); if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') { return null; } - const { caseSettingFieldsComponent: FieldsComponent } = caseSettingsRegistry.get( - connector.actionTypeId - ); + const { fieldsComponent: FieldsComponent } = caseConnectorsRegistry.get(connector.actionTypeId); return ( <> @@ -39,7 +37,7 @@ const SettingFieldsFormComponent: React.FC = ({ connector, isEdit, onChan } > -
+
= ({ connector, isEdit, onChan ); }; -export const SettingFieldsForm = memo(SettingFieldsFormComponent); +export const ConnectorFieldsForm = memo(ConnectorFieldsFormComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts index 96cb215557c24f..267126fc6ec8b5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts @@ -5,7 +5,53 @@ * 2.0. */ +import { CaseConnectorsRegistry } from './types'; +import { createCaseConnectorsRegistry } from './connectors_registry'; +import { getCaseConnector as getJiraCaseConnector } from './jira'; +import { getCaseConnector as getResilientCaseConnector } from './resilient'; +import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; +import { + JiraFieldsType, + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, + ResilientFieldsType, +} from '../../../../../case/common/api/connectors'; + export { getActionType as getCaseConnectorUI } from './case'; export * from './config'; export * from './types'; + +interface GetCaseConnectorsReturn { + caseConnectorsRegistry: CaseConnectorsRegistry; +} + +class CaseConnectors { + private caseConnectorsRegistry: CaseConnectorsRegistry; + + constructor() { + this.caseConnectorsRegistry = createCaseConnectorsRegistry(); + this.init(); + } + + private init() { + this.caseConnectorsRegistry.register(getJiraCaseConnector()); + this.caseConnectorsRegistry.register(getResilientCaseConnector()); + this.caseConnectorsRegistry.register( + getServiceNowITSMCaseConnector() + ); + this.caseConnectorsRegistry.register(getServiceNowSIRCaseConnector()); + } + + registry(): CaseConnectorsRegistry { + return this.caseConnectorsRegistry; + } +} + +const caseConnectors = new CaseConnectors(); + +export const getCaseConnectors = (): GetCaseConnectorsReturn => { + return { + caseConnectorsRegistry: caseConnectors.registry(), + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/__mocks__/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/__mocks__/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.test.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/api.test.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx index 0c590d0ecd7ad3..b151d41c4cdd8b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx @@ -12,7 +12,7 @@ import { omit } from 'lodash/fp'; import { connector, issues } from '../mock'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; -import Fields from './fields'; +import Fields from './case_fields'; import { waitFor } from '@testing-library/dom'; import { useGetSingleIssue } from './use_get_single_issue'; import { useGetIssues } from './use_get_issues'; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx index 6409fe71a85fc3..d768b552b78b44 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx @@ -5,25 +5,26 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useEffect, useRef } from 'react'; import { map } from 'lodash/fp'; import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import * as i18n from './translations'; import { ConnectorTypes, JiraFieldsType } from '../../../../../../case/common/api/connectors'; import { useKibana } from '../../../../common/lib/kibana'; -import { SettingFieldsProps } from '../types'; +import { ConnectorFieldsProps } from '../types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; import { ConnectorCard } from '../card'; -const JiraSettingFieldsComponent: React.FunctionComponent> = ({ +const JiraFieldsComponent: React.FunctionComponent> = ({ connector, fields, isEdit = true, onChange, }) => { + const init = useRef(true); const { issueType = null, priority = null, parent = null } = fields ?? {}; const { http, notifications } = useKibana().services; @@ -138,8 +139,16 @@ const JiraSettingFieldsComponent: React.FunctionComponent { + if (init.current) { + init.current = false; + onChange({ issueType, priority, parent }); + } + }, [issueType, onChange, parent, priority]); + return isEdit ? ( -
+
=> { +export const getCaseConnector = (): CaseConnector => { return { id: '.jira', - caseSettingFieldsComponent: lazy(() => import('./fields')), + fieldsComponent: lazy(() => import('./case_fields')), }; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/search_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/search_issues.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts similarity index 60% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts index 65fe339aceb674..07f8f5b984cdde 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts @@ -8,69 +8,69 @@ import { i18n } from '@kbn/i18n'; export const ISSUE_TYPES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.settings.jira.unableToGetIssueTypesMessage', + 'xpack.securitySolution.components.connectors.jira.unableToGetIssueTypesMessage', { defaultMessage: 'Unable to get issue types', } ); export const FIELDS_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.settings.jira.unableToGetFieldsMessage', + 'xpack.securitySolution.components.connectors.jira.unableToGetFieldsMessage', { - defaultMessage: 'Unable to get fields', + defaultMessage: 'Unable to get connectors', } ); export const ISSUES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.settings.jira.unableToGetIssuesMessage', + 'xpack.securitySolution.components.connectors.jira.unableToGetIssuesMessage', { defaultMessage: 'Unable to get issues', } ); export const GET_ISSUE_API_ERROR = (id: string) => - i18n.translate('xpack.securitySolution.components.settings.jira.unableToGetIssueMessage', { + i18n.translate('xpack.securitySolution.components.connectors.jira.unableToGetIssueMessage', { defaultMessage: 'Unable to get issue with id {id}', values: { id }, }); export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxAriaLabel', + 'xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxAriaLabel', { defaultMessage: 'Type to search', } ); export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxPlaceholder', + 'xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxPlaceholder', { defaultMessage: 'Type to search', } ); export const SEARCH_ISSUES_LOADING = i18n.translate( - 'xpack.securitySolution.components.settings.jira.searchIssuesLoading', + 'xpack.securitySolution.components.connectors.jira.searchIssuesLoading', { defaultMessage: 'Loading...', } ); export const PRIORITY = i18n.translate( - 'xpack.securitySolution.case.settings.jira.prioritySelectFieldLabel', + 'xpack.securitySolution.case.connectors.jira.prioritySelectFieldLabel', { defaultMessage: 'Priority', } ); export const ISSUE_TYPE = i18n.translate( - 'xpack.securitySolution.case.settings.jira.issueTypesSelectFieldLabel', + 'xpack.securitySolution.case.connectors.jira.issueTypesSelectFieldLabel', { defaultMessage: 'Issue type', } ); export const PARENT_ISSUE = i18n.translate( - 'xpack.securitySolution.case.settings.jira.parentIssueSearchLabel', + 'xpack.securitySolution.case.connectors.jira.parentIssueSearchLabel', { defaultMessage: 'Parent issue', } diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/types.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts new file mode 100644 index 00000000000000..04e7338025258e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const connector = { + id: '123', + name: 'My connector', + actionTypeId: '.jira', + config: {}, + isPreconfigured: false, +}; + +export const issues = [ + { id: 'personId', title: 'Person Task', key: 'personKey' }, + { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, + { id: 'manId', title: 'Man Task', key: 'manKey' }, + { id: 'cameraId', title: 'Camera Task', key: 'cameraKey' }, + { id: 'tvId', title: 'TV Task', key: 'tvKey' }, +]; + +export const choices = [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound or outbound', + value: '12', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Single or distributed (DoS or DDoS)', + value: '26', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound DDos', + value: 'inbound_ddos', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact', 'priority'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), +]; + +export const severity = [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, +]; + +export const incidentTypes = [ + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, +]; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts similarity index 70% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts index f4397eaf1877cb..c27248288907dc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts @@ -5,29 +5,10 @@ * 2.0. */ +import { incidentTypes, severity } from '../../mock'; import { Props } from '../api'; import { ResilientIncidentTypes, ResilientSeverity } from '../types'; -const severity = [ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, -]; - -const incidentTypes = [ - { id: 17, name: 'Communication error (fax; email)' }, - { id: 1001, name: 'Custom type' }, -]; - export const getIncidentTypes = async (props: Props): Promise<{ data: ResilientIncidentTypes }> => Promise.resolve({ data: incidentTypes }); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx index 9095f3b56f2c38..dd13083288020b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx @@ -13,7 +13,7 @@ import { waitFor } from '@testing-library/react'; import { connector } from '../mock'; import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; -import Fields from './fields'; +import Fields from './case_fields'; jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_incident_types'); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx index f79ce8a4a56301..8c62f5285c257a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption, @@ -16,8 +16,7 @@ import { } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; -import { SettingFieldsProps } from '../types'; - +import { ConnectorFieldsProps } from '../types'; import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; @@ -25,9 +24,10 @@ import * as i18n from './translations'; import { ConnectorTypes, ResilientFieldsType } from '../../../../../../case/common/api/connectors'; import { ConnectorCard } from '../card'; -const ResilientSettingFieldsComponent: React.FunctionComponent< - SettingFieldsProps +const ResilientFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps > = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); const { incidentTypes = null, severityCode = null } = fields ?? {}; const { http, notifications } = useKibana().services; @@ -136,14 +136,16 @@ const ResilientSettingFieldsComponent: React.FunctionComponent< } }, [incidentTypes, onFieldChange]); - // We need to set them up at initialization + // Set field at initialization useEffect(() => { - onChange({ incidentTypes, severityCode }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (init.current) { + init.current = false; + onChange({ incidentTypes, severityCode }); + } + }, [incidentTypes, onChange, severityCode]); return isEdit ? ( - + => { +export const getCaseConnector = (): CaseConnector => { return { id: '.resilient', - caseSettingFieldsComponent: lazy(() => import('./fields')), + fieldsComponent: lazy(() => import('./case_fields')), }; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts similarity index 67% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts index 648baf840884b9..32a72c38037087 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts @@ -8,35 +8,35 @@ import { i18n } from '@kbn/i18n'; export const INCIDENT_TYPES_API_ERROR = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.unableToGetIncidentTypesMessage', + 'xpack.securitySolution.case.connectors.resilient.unableToGetIncidentTypesMessage', { defaultMessage: 'Unable to get incident types', } ); export const SEVERITY_API_ERROR = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.unableToGetSeverityMessage', + 'xpack.securitySolution.case.connectors.resilient.unableToGetSeverityMessage', { defaultMessage: 'Unable to get severity', } ); export const INCIDENT_TYPES_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.incidentTypesPlaceholder', + 'xpack.securitySolution.case.connectors.resilient.incidentTypesPlaceholder', { defaultMessage: 'Choose types', } ); export const INCIDENT_TYPES_LABEL = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.incidentTypesLabel', + 'xpack.securitySolution.case.connectors.resilient.incidentTypesLabel', { defaultMessage: 'Incident Types', } ); export const SEVERITY_LABEL = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.severityLabel', + 'xpack.securitySolution.case.connectors.resilient.severityLabel', { defaultMessage: 'Severity', } diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/types.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts new file mode 100644 index 00000000000000..215e3d6f92e6d9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { choices } from '../../mock'; +import { GetChoicesProps } from '../api'; +import { Choice } from '../types'; + +export const choicesResponse = { + status: 'ok', + data: choices, +}; + +export const getChoices = async ( + props: GetChoicesProps +): Promise<{ status: string; data: Choice[] }> => Promise.resolve(choicesResponse); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts new file mode 100644 index 00000000000000..6a6bb7e9479977 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { getChoices } from './api'; +import { choices } from '../mock'; + +const choicesResponse = { + status: 'ok', + data: choices, +}; + +describe('ServiceNow API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getChoices', () => { + test('should call get choices API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(choicesResponse); + const res = await getChoices({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + fields: ['priority'], + }); + + expect(res).toEqual(choicesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts new file mode 100644 index 00000000000000..d91ad9f8762bda --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../../actions/common'; +import { Choice } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface GetChoicesProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + fields: string[]; +} + +export async function getChoices({ http, signal, connectorId, fields }: GetChoicesProps) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'getChoices', subActionParams: { fields } }, + }), + signal, + } + ); +} diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts new file mode 100644 index 00000000000000..81bd81124599f2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, +} from '../../../../../../case/common/api/connectors'; +import * as i18n from './translations'; + +export const getServiceNowITSMCaseConnector = (): CaseConnector => { + return { + id: '.servicenow', + fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')), + }; +}; + +export const getServiceNowSIRCaseConnector = (): CaseConnector => { + return { + id: '.servicenow-sir', + fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')), + }; +}; + +export const serviceNowITSMFieldLabels = { + impact: i18n.IMPACT, + severity: i18n.SEVERITY, + urgency: i18n.URGENCY, +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx similarity index 52% rename from x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index 2e56e21aa8e98b..555ed0dcbb1618 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -6,36 +6,74 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import Fields from './fields'; -import { connector } from '../mock'; -import { waitFor } from '@testing-library/dom'; +import { waitFor, act } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; +import { mount } from 'enzyme'; + +import { connector, choices as mockChoices } from '../mock'; +import { Choice } from './types'; +import Fields from './servicenow_itsm_case_fields'; + +let onChoicesSuccess = (c: Choice[]) => {}; -describe('ServiceNow Fields', () => { +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_choices', () => ({ + useGetChoices: (args: { onSuccess: () => void }) => { + onChoicesSuccess = args.onSuccess; + return { isLoading: false, mockChoices }; + }, +})); + +describe('ServiceNowITSM Fields', () => { const fields = { severity: '1', urgency: '2', impact: '3' }; const onChange = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); }); + it('all params fields are rendered - isEdit: true', () => { const wrapper = mount(); - expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toEqual('1'); - expect(wrapper.find('[data-test-subj="urgencySelect"]').first().prop('value')).toEqual('2'); - expect(wrapper.find('[data-test-subj="impactSelect"]').first().prop('value')).toEqual('3'); + expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); }); - test('all params fields are rendered - isEdit: false', () => { + it('all params fields are rendered - isEdit: false', () => { const wrapper = mount( ); + act(() => { + onChoicesSuccess(mockChoices); + }); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( - 'Urgency: Medium' + 'Urgency: 2 - High' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( - 'Severity: High' + 'Severity: 1 - Critical' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Impact: 3 - Moderate' + ); + }); + + it('it transforms the options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + const testers = ['severity', 'urgency', 'impact']; + testers.forEach((subj) => + expect(wrapper.find(`[data-test-subj="${subj}Select"]`).first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]) ); - expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual('Impact: Low'); }); describe('onChange calls', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx new file mode 100644 index 00000000000000..e278492b57148a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +import { ConnectorFieldsProps } from '../types'; +import { + ConnectorTypes, + ServiceNowITSMFieldsType, +} from '../../../../../../case/common/api/connectors'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorCard } from '../card'; +import { useGetChoices } from './use_get_choices'; +import { Options, Choice } from './types'; + +const useGetChoicesFields = ['urgency', 'severity', 'impact']; +const defaultOptions: Options = { + urgency: [], + severity: [], + impact: [], +}; + +const ServiceNowITSMFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { severity = null, urgency = null, impact = null } = fields ?? {}; + const { http, notifications } = useKibana().services; + const [options, setOptions] = useState(defaultOptions); + + const listItems = useMemo( + () => [ + ...(urgency != null && urgency.length > 0 + ? [ + { + title: i18n.URGENCY, + description: options.urgency.find((option) => `${option.value}` === urgency)?.text, + }, + ] + : []), + ...(severity != null && severity.length > 0 + ? [ + { + title: i18n.SEVERITY, + description: options.severity.find((option) => `${option.value}` === severity)?.text, + }, + ] + : []), + ...(impact != null && impact.length > 0 + ? [ + { + title: i18n.IMPACT, + description: options.impact.find((option) => `${option.value}` === impact)?.text, + }, + ] + : []), + ], + [urgency, options.urgency, options.severity, options.impact, severity, impact] + ); + + const onChoicesSuccess = (choices: Choice[]) => + setOptions( + choices.reduce( + (acc, choice) => ({ + ...acc, + [choice.element]: [ + ...(acc[choice.element] != null ? acc[choice.element] : []), + { value: choice.value, text: choice.label }, + ], + }), + defaultOptions + ) + ); + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: notifications.toasts, + connector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const onChangeCb = useCallback( + ( + key: keyof ServiceNowITSMFieldsType, + value: ServiceNowITSMFieldsType[keyof ServiceNowITSMFieldsType] + ) => { + onChange({ ...fields, [key]: value }); + }, + [fields, onChange] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ urgency, severity, impact }); + } + }, [impact, onChange, severity, urgency]); + + return isEdit ? ( +
+ + onChangeCb('urgency', e.target.value)} + /> + + + + + + onChangeCb('severity', e.target.value)} + /> + + + + + onChangeCb('impact', e.target.value)} + /> + + + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowITSMFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx new file mode 100644 index 00000000000000..7d785406afec83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { waitFor, act } from '@testing-library/react'; +import { EuiSelect } from '@elastic/eui'; + +import { connector, choices as mockChoices } from '../mock'; +import { Choice } from './types'; +import Fields from './servicenow_sir_case_fields'; + +let onChoicesSuccess = (c: Choice[]) => {}; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_choices', () => ({ + useGetChoices: (args: { onSuccess: () => void }) => { + onChoicesSuccess = args.onSuccess; + return { isLoading: false, mockChoices }; + }, +})); + +describe('ServiceNowSIR Fields', () => { + const fields = { + destIp: true, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }; + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('all params fields are rendered - isEdit: true', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="destIpCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceIpCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malwareUrlCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malwareHashCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); + }); + + test('all params fields are rendered - isEdit: false', () => { + const wrapper = mount( + + ); + act(() => { + onChoicesSuccess(mockChoices); + }); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( + 'Destination IP: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( + 'Source IP: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Malware URL: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual( + 'Malware Hash: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual( + 'Priority: 1 - Critical' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(5).text()).toEqual( + 'Category: Denial of Service' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(6).text()).toEqual( + 'Subcategory: Single or distributed (DoS or DDoS)' + ); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Inbound or outbound', + value: '12', + }, + { + text: 'Single or distributed (DoS or DDoS)', + value: '26', + }, + { + text: 'Inbound DDos', + value: 'inbound_ddos', + }, + ]); + }); + + test('it transforms the priorities to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('options')).toEqual([ + { + text: '1 - Critical', + value: '1', + }, + { + text: '2 - High', + value: '2', + }, + { + text: '3 - Moderate', + value: '3', + }, + { + text: '4 - Low', + value: '4', + }, + ]); + }); + + describe('onChange calls', () => { + const wrapper = mount(); + + act(() => { + onChoicesSuccess(mockChoices); + }); + wrapper.update(); + + expect(onChange).toHaveBeenCalledWith(fields); + + const checkbox = ['destIp', 'sourceIp', 'malwareHash', 'malwareUrl']; + checkbox.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + wrapper + .find(`[data-test-subj="${subj}Checkbox"] input`) + .first() + .simulate('change', { target: { checked: false } }); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: false, + }); + }); + }) + ); + + const testers = ['priority', 'category', 'subcategory']; + testers.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="${subj}Select"]`)!; + select.prop('onChange')!({ + target: { + value: '9', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: '9', + }); + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx new file mode 100644 index 00000000000000..96db43fe261ac5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -0,0 +1,293 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSelectOption, + EuiCheckbox, +} from '@elastic/eui'; + +import { + ConnectorTypes, + ServiceNowSIRFieldsType, +} from '../../../../../../case/common/api/connectors'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorFieldsProps } from '../types'; +import { ConnectorCard } from '../card'; +import { useGetChoices } from './use_get_choices'; +import { Choice, Fields } from './types'; + +import * as i18n from './translations'; + +const useGetChoicesFields = ['category', 'subcategory', 'priority']; +const defaultFields: Fields = { + category: [], + subcategory: [], + priority: [], +}; + +const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); + +const ServiceNowSIRFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { + category = null, + destIp = true, + malwareHash = true, + malwareUrl = true, + priority = null, + sourceIp = true, + subcategory = null, + } = fields ?? {}; + + const { http, notifications } = useKibana().services; + + const [choices, setChoices] = useState(defaultFields); + + const onChangeCb = useCallback( + ( + key: keyof ServiceNowSIRFieldsType, + value: ServiceNowSIRFieldsType[keyof ServiceNowSIRFieldsType] + ) => { + onChange({ ...fields, [key]: value }); + }, + [fields, onChange] + ); + + const onChoicesSuccess = (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], + }), + defaultFields + ) + ); + }; + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: notifications.toasts, + connector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const priorityOptions = useMemo(() => choicesToEuiOptions(choices.priority), [choices.priority]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter((choice) => choice.dependent_value === category) + ), + [choices.subcategory, category] + ); + + const listItems = useMemo( + () => [ + ...(destIp != null && destIp + ? [ + { + title: i18n.DEST_IP, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(sourceIp != null && sourceIp + ? [ + { + title: i18n.SOURCE_IP, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(malwareUrl != null && malwareUrl + ? [ + { + title: i18n.MALWARE_URL, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(malwareHash != null && malwareHash + ? [ + { + title: i18n.MALWARE_HASH, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(priority != null && priority.length > 0 + ? [ + { + title: i18n.PRIORITY, + description: priorityOptions.find((option) => `${option.value}` === priority)?.text, + }, + ] + : []), + ...(category != null && category.length > 0 + ? [ + { + title: i18n.CATEGORY, + description: categoryOptions.find((option) => `${option.value}` === category)?.text, + }, + ] + : []), + ...(subcategory != null && subcategory.length > 0 + ? [ + { + title: i18n.SUBCATEGORY, + description: subcategoryOptions.find((option) => `${option.value}` === subcategory) + ?.text, + }, + ] + : []), + ], + [ + category, + categoryOptions, + destIp, + malwareHash, + malwareUrl, + priority, + priorityOptions, + sourceIp, + subcategory, + subcategoryOptions, + ] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ category, destIp, malwareHash, malwareUrl, priority, sourceIp, subcategory }); + } + }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); + + return isEdit ? ( +
+ + + + <> + + + onChangeCb('destIp', e.target.checked)} + /> + + + onChangeCb('sourceIp', e.target.checked)} + /> + + + + + onChangeCb('malwareUrl', e.target.checked)} + /> + + + onChangeCb('malwareHash', e.target.checked)} + /> + + + + + + + + + + onChangeCb('priority', e.target.value)} + /> + + + + + + + onChangeCb('category', e.target.value)} + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowSIRFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts new file mode 100644 index 00000000000000..0867dc41eeb786 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const URGENCY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.urgencySelectFieldLabel', + { + defaultMessage: 'Urgency', + } +); + +export const SEVERITY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.severitySelectFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const IMPACT = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.impactSelectFieldLabel', + { + defaultMessage: 'Impact', + } +); + +export const CHOICES_API_ERROR = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.unableToGetChoicesMessage', + { + defaultMessage: 'Unable to get choices', + } +); + +export const MALWARE_URL = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.malwareURLTitle', + { + defaultMessage: 'Malware URL', + } +); + +export const MALWARE_HASH = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.malwareHashTitle', + { + defaultMessage: 'Malware Hash', + } +); + +export const CATEGORY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.categoryTitle', + { + defaultMessage: 'Category', + } +); + +export const SUBCATEGORY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.subcategoryTitle', + { + defaultMessage: 'Subcategory', + } +); + +export const SOURCE_IP = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.sourceIPTitle', + { + defaultMessage: 'Source IP', + } +); + +export const DEST_IP = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.destinationIPTitle', + { + defaultMessage: 'Destination IP', + } +); + +export const PRIORITY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.prioritySelectFieldTitle', + { + defaultMessage: 'Priority', + } +); + +export const ALERT_FIELDS_LABEL = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle', + { + defaultMessage: 'Fields associated with alerts', + } +); + +export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.alertFieldEnabledText', + { + defaultMessage: 'Yes', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts new file mode 100644 index 00000000000000..deceeed29482b5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSelectOption } from '@elastic/eui'; + +export interface Choice { + value: string; + label: string; + dependent_value: string; + element: string; +} + +export type Fields = Record; +export type Options = Record; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx new file mode 100644 index 00000000000000..2492fbaaf5a831 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionConnector } from '../../../containers/types'; +import { choices } from '../mock'; +import { useGetChoices, UseGetChoices, UseGetChoicesProps } from './use_get_choices'; +import * as api from './api'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; +const onSuccess = jest.fn(); +const fields = ['priority']; + +const connector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, +} as ActionConnector; + +describe('useGetChoices', () => { + const { services } = useKibanaMock(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + choices, + }); + }); + + it('returns an empty array when connector is not presented', async () => { + const { result } = renderHook(() => + useGetChoices({ + http: services.http, + connector: undefined, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: [], + }); + }); + + it('it calls onSuccess', async () => { + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(onSuccess).toHaveBeenCalledWith(choices); + }); + + it('it displays an error when service fails', async () => { + const spyOnGetChoices = jest.spyOn(api, 'getChoices'); + spyOnGetChoices.mockResolvedValue( + Promise.resolve({ + actionId: 'test', + status: 'error', + serviceMessage: 'An error occurred', + }) + ); + + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); + + it('it displays an error when http throws an error', async () => { + const spyOnGetChoices = jest.spyOn(api, 'getChoices'); + spyOnGetChoices.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx new file mode 100644 index 00000000000000..16e905bdabfee9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getChoices } from './api'; +import { Choice } from './types'; +import * as i18n from './translations'; + +export interface UseGetChoicesProps { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; + fields: string[]; + onSuccess?: (choices: Choice[]) => void; +} + +export interface UseGetChoices { + choices: Choice[]; + isLoading: boolean; +} + +export const useGetChoices = ({ + http, + connector, + toastNotifications, + fields, + onSuccess, +}: UseGetChoicesProps): UseGetChoices => { + const [isLoading, setIsLoading] = useState(false); + const [choices, setChoices] = useState([]); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!connector) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + + try { + const res = await getChoices({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + fields, + }); + + if (!didCancel) { + setIsLoading(false); + setChoices(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } else if (onSuccess) { + onSuccess(res.data ?? []); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications, fields]); + + return { + choices, + isLoading, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts index 808e185eabb6f0..46c707197fdb47 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts @@ -5,14 +5,17 @@ * 2.0. */ -import { ActionType } from '../../../../../triggers_actions_ui/public'; +import React from 'react'; import { ActionType as ThirdPartySupportedActions, CaseField, + ActionConnector, + ConnectorTypeFields, } from '../../../../../case/common/api'; export { ThirdPartyField as AllThirdPartyFields } from '../../../../../case/common/api'; +export type CaseActionConnector = ActionConnector; export interface ThirdPartyField { label: string; @@ -21,6 +24,30 @@ export interface ThirdPartyField { defaultActionType: ThirdPartySupportedActions; } -export interface ConnectorConfiguration extends ActionType { +export interface ConnectorConfiguration { + name: string; logo: string; } + +export interface CaseConnector { + id: string; + fieldsComponent: React.LazyExoticComponent< + React.ComponentType> + > | null; +} + +export interface CaseConnectorsRegistry { + has: (id: string) => boolean; + register: ( + connector: CaseConnector + ) => void; + get: (id: string) => CaseConnector; + list: () => CaseConnector[]; +} + +export interface ConnectorFieldsProps { + isEdit?: boolean; + connector: CaseActionConnector; + fields: TFields; + onChange: (fields: TFields) => void; +} diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx index 2a361a2f6cdcea..236c13e5afc08e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx @@ -14,8 +14,10 @@ import { useForm, Form, FormHook } from '../../../shared_imports'; import { connectorsMock } from '../../containers/mock'; import { Connector } from './connector'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types'; -import { useGetSeverity } from '../settings/resilient/use_get_severity'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; jest.mock('../../../common/lib/kibana', () => { @@ -29,43 +31,28 @@ jest.mock('../../../common/lib/kibana', () => { }; }); jest.mock('../../containers/configure/use_connectors'); -jest.mock('../settings/resilient/use_get_incident_types'); -jest.mock('../settings/resilient/use_get_severity'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/servicenow/use_get_choices'); const useConnectorsMock = useConnectors as jest.Mock; const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; const useGetIncidentTypesResponse = { isLoading: false, - incidentTypes: [ - { - id: 19, - name: 'Malware', - }, - { - id: 21, - name: 'Denial of Service', - }, - ], + incidentTypes, }; const useGetSeverityResponse = { isLoading: false, - severity: [ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, - ], + severity, +}; + +const useGetChoicesResponse = { + isLoading: false, + choices, }; describe('Connector', () => { @@ -90,6 +77,7 @@ describe('Connector', () => { useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); }); it('it renders', async () => { @@ -100,7 +88,7 @@ describe('Connector', () => { ); expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="connector-settings"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy(); await waitFor(() => { expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe( @@ -108,10 +96,10 @@ describe('Connector', () => { ); }); - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); - }); + // await waitFor(() => { + // wrapper.update(); + // expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); + // }); }); it('it is loading when fetching connectors', async () => { @@ -163,7 +151,7 @@ describe('Connector', () => { ); await waitFor(() => { - expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); wrapper.update(); @@ -171,7 +159,7 @@ describe('Connector', () => { await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); }); act(() => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index 4a8b25f4f7b456..5e7972aec9d4be 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; +import React, { memo, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { UseField, useFormData, FieldHook } from '../../../shared_imports'; +import { UseField, useFormData, FieldHook, useFormContext } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; -import { SettingFieldsForm } from '../settings/fields_form'; +import { ConnectorFieldsForm } from '../connectors/fields_form'; import { ActionConnector } from '../../containers/types'; import { getConnectorById } from '../configure_cases/utils'; import { FormProps } from './schema'; @@ -20,25 +20,19 @@ interface Props { isLoading: boolean; } -interface SettingsFieldProps { +interface ConnectorsFieldProps { connectors: ActionConnector[]; field: FieldHook; isEdit: boolean; } -const SettingsField = ({ connectors, isEdit, field }: SettingsFieldProps) => { +const ConnectorFields = ({ connectors, isEdit, field }: ConnectorsFieldProps) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const { setValue } = field; const connector = getConnectorById(connectorId, connectors) ?? null; - useEffect(() => { - if (connectorId) { - setValue(null); - } - }, [setValue, connectorId]); - return ( - { }; const ConnectorComponent: React.FC = ({ isLoading }) => { + const { getFields } = useFormContext(); const { loading: isLoadingConnectors, connectors } = useConnectors(); + const handleConnectorChange = useCallback( + (newConnector) => { + const { fields } = getFields(); + fields.setValue(null); + }, + [getFields] + ); return ( @@ -58,6 +60,7 @@ const ConnectorComponent: React.FC = ({ isLoading }) => { component={ConnectorSelector} componentProps={{ connectors, + handleChange: handleConnectorChange, dataTestSubj: 'caseConnectors', disabled: isLoading || isLoadingConnectors, idAria: 'caseConnectors', @@ -68,7 +71,7 @@ const ConnectorComponent: React.FC = ({ isLoading }) => { { @@ -189,7 +186,7 @@ describe('Create case', () => { connector: { id: 'servicenow-1', name: 'SN', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, persistLoading: false, @@ -237,7 +234,7 @@ describe('Create case', () => { connector: { id: 'not-exist', name: 'SN', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, persistLoading: false, @@ -261,7 +258,7 @@ describe('Create case', () => { wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => { expect(postCase).toBeCalledWith(sampleData); - expect(postPushToService).not.toHaveBeenCalled(); + expect(pushCaseToExternalService).not.toHaveBeenCalled(); }); }); }); @@ -283,13 +280,13 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); }); wrapper @@ -318,17 +315,14 @@ describe('Create case', () => { fields: { issueType: '10007', parent: null, priority: '2' }, }, }); - expect(postPushToService).toHaveBeenCalledWith({ + expect(pushCaseToExternalService).toHaveBeenCalledWith({ caseId: sampleId, - caseServices: {}, connector: { id: 'jira-1', name: 'Jira', type: '.jira', fields: { issueType: '10007', parent: null, priority: '2' }, }, - alerts: {}, - updateCase: noop, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ id: sampleId, @@ -353,15 +347,13 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); await waitFor(() => { wrapper.update(); - expect( - wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists() - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); }); act(() => { @@ -390,17 +382,14 @@ describe('Create case', () => { }, }); - expect(postPushToService).toHaveBeenCalledWith({ + expect(pushCaseToExternalService).toHaveBeenCalledWith({ caseId: sampleId, - caseServices: {}, connector: { id: 'resilient-2', name: 'My Connector 2', type: '.resilient', fields: { incidentTypes: ['19'], severityCode: '4' }, }, - alerts: {}, - updateCase: noop, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ @@ -426,10 +415,10 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { wrapper @@ -453,17 +442,14 @@ describe('Create case', () => { }, }); - expect(postPushToService).toHaveBeenCalledWith({ + expect(pushCaseToExternalService).toHaveBeenCalledWith({ caseId: sampleId, - caseServices: {}, connector: { id: 'servicenow-1', name: 'My Connector', type: '.servicenow', fields: { impact: '2', severity: '2', urgency: '2' }, }, - alerts: {}, - updateCase: noop, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index 20ec1e9177cd37..cc38e07cf49e4a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -6,7 +6,6 @@ */ import React, { useCallback, useEffect, useMemo } from 'react'; -import { noop } from 'lodash/fp'; import { schema, FormProps } from './schema'; import { Form, useForm } from '../../../shared_imports'; import { @@ -38,7 +37,7 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); - const { postPushToService } = usePostPushToService(); + const { pushCaseToExternalService } = usePostPushToService(); const connectorId = useMemo( () => @@ -67,12 +66,9 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { }); if (updatedCase?.id && dataConnectorId !== 'none') { - await postPushToService({ + await pushCaseToExternalService({ caseId: updatedCase.id, - caseServices: {}, connector: connectorToUpdate, - alerts: {}, - updateCase: noop, }); } @@ -81,7 +77,7 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { } } }, - [connectors, postCase, onSuccess, postPushToService] + [connectors, postCase, onSuccess, pushCaseToExternalService] ); const { form } = useForm({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index f43aecdc123a63..7172d227f492e2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -16,10 +16,10 @@ import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; -import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types'; -import { useGetSeverity } from '../settings/resilient/use_get_severity'; -import { useGetIssueTypes } from '../settings/jira/use_get_issue_types'; -import { useGetFieldsByIssueType } from '../settings/jira/use_get_fields_by_issue_type'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { useInsertTimeline } from '../use_insert_timeline'; import { @@ -37,12 +37,12 @@ jest.mock('../../containers/api'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); -jest.mock('../settings/resilient/use_get_incident_types'); -jest.mock('../settings/resilient/use_get_severity'); -jest.mock('../settings/jira/use_get_issue_types'); -jest.mock('../settings/jira/use_get_fields_by_issue_type'); -jest.mock('../settings/jira/use_get_single_issue'); -jest.mock('../settings/jira/use_get_issues'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/jira/use_get_issue_types'); +jest.mock('../connectors/jira/use_get_fields_by_issue_type'); +jest.mock('../connectors/jira/use_get_single_issue'); +jest.mock('../connectors/jira/use_get_issues'); jest.mock('../use_insert_timeline'); const useConnectorsMock = useConnectors as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index 21a87e3a64ac0b..34dcacaf42a981 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -23,8 +23,8 @@ import { noop } from 'lodash/fp'; import { Form, UseField, useForm } from '../../../shared_imports'; import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; import { ConnectorSelector } from '../connector_selector/form'; -import { ActionConnector } from '../../../../../case/common/api/cases'; -import { SettingFieldsForm } from '../settings/fields_form'; +import { ActionConnector } from '../../../../../case/common/api'; +import { ConnectorFieldsForm } from '../connectors/fields_form'; import { getConnectorById } from '../configure_cases/utils'; import { CaseUserActions } from '../../containers/types'; import { schema } from './schema'; @@ -244,7 +244,7 @@ export const EditConnector = React.memo( - + {(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined. !(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted. !editConnector && ( @@ -252,7 +252,7 @@ export const EditConnector = React.memo( {i18n.NO_CONNECTOR} )} - (getJiraCaseSetting()); - this.caseSettingsRegistry.register(getResilientCaseSetting()); - this.caseSettingsRegistry.register(getServiceNowCaseSetting()); - } - - registry(): CaseSettingsRegistry { - return this.caseSettingsRegistry; - } -} - -const caseSettings = new CaseSettings(); - -export const getCaseSettings = (): GetCaseSettingReturn => { - return { - caseSettingsRegistry: caseSettings.registry(), - }; -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts b/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts deleted file mode 100644 index 69f30b488d9a6a..00000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const connector = { - id: '123', - name: 'My connector', - actionTypeId: '.jira', - config: {}, - isPreconfigured: false, -}; -export const issues = [ - { id: 'personId', title: 'Person Task', key: 'personKey' }, - { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, - { id: 'manId', title: 'Man Task', key: 'manKey' }, - { id: 'cameraId', title: 'Camera Task', key: 'cameraKey' }, - { id: 'tvId', title: 'TV Task', key: 'tvKey' }, -]; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx deleted file mode 100644 index 161e4d44cd5729..00000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useEffect, useMemo } from 'react'; -import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import * as i18n from './translations'; - -import { SettingFieldsProps } from '../types'; -import { ConnectorTypes, ServiceNowFieldsType } from '../../../../../../case/common/api/connectors'; -import { ConnectorCard } from '../card'; - -const selectOptions = [ - { - value: '1', - text: i18n.SEVERITY_HIGH, - }, - { - value: '2', - text: i18n.SEVERITY_MEDIUM, - }, - { - value: '3', - text: i18n.SEVERITY_LOW, - }, -]; - -const ServiceNowSettingFieldsComponent: React.FunctionComponent< - SettingFieldsProps -> = ({ isEdit = true, fields, connector, onChange }) => { - const { severity = null, urgency = null, impact = null } = fields ?? {}; - - const listItems = useMemo( - () => [ - ...(urgency != null && urgency.length > 0 - ? [ - { - title: i18n.URGENCY, - description: selectOptions.find((option) => `${option.value}` === urgency)?.text, - }, - ] - : []), - ...(severity != null && severity.length > 0 - ? [ - { - title: i18n.SEVERITY, - description: selectOptions.find((option) => `${option.value}` === severity)?.text, - }, - ] - : []), - ...(impact != null && impact.length > 0 - ? [ - { - title: i18n.IMPACT, - description: selectOptions.find((option) => `${option.value}` === impact)?.text, - }, - ] - : []), - ], - [urgency, severity, impact] - ); - - // We need to set them up at initialization - useEffect(() => { - onChange({ impact, severity, urgency }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onChangeCb = useCallback( - (key: keyof ServiceNowFieldsType, value: ServiceNowFieldsType[keyof ServiceNowFieldsType]) => { - onChange({ ...fields, [key]: value }); - }, - [fields, onChange] - ); - - return isEdit ? ( - - - onChangeCb('urgency', e.target.value)} - /> - - - - - - onChangeCb('severity', e.target.value)} - /> - - - - - onChangeCb('impact', e.target.value)} - /> - - - - - ) : ( - - ); -}; - -// eslint-disable-next-line import/no-default-export -export { ServiceNowSettingFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts deleted file mode 100644 index 70d1bf89ce7c8f..00000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { lazy } from 'react'; - -import { CaseSetting } from '../types'; -import { ServiceNowFieldsType } from '../../../../../../case/common/api/connectors'; -import * as i18n from './translations'; - -export const getCaseSetting = (): CaseSetting => { - return { - id: '.servicenow', - caseSettingFieldsComponent: lazy(() => import('./fields')), - }; -}; - -export const fieldLabels = { - impact: i18n.IMPACT, - severity: i18n.SEVERITY, - urgency: i18n.URGENCY, -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts deleted file mode 100644 index 6db239541851eb..00000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const SEVERITY_HIGH = i18n.translate( - 'xpack.securitySolution.components.settings.servicenow.severitySelectHighOptionLabel', - { - defaultMessage: 'High', - } -); -export const SEVERITY_MEDIUM = i18n.translate( - 'xpack.securitySolution.components.settings.servicenow.severitySelectMediumOptionLabel', - { - defaultMessage: 'Medium', - } -); - -export const SEVERITY_LOW = i18n.translate( - 'xpack.securitySolution.components.settings.servicenow.severitySelectLowOptionLabel', - { - defaultMessage: 'Low', - } -); - -export const URGENCY = i18n.translate( - 'xpack.securitySolution.components.settings.serviceNow.urgencySelectFieldLabel', - { - defaultMessage: 'Urgency', - } -); - -export const SEVERITY = i18n.translate( - 'xpack.securitySolution.components.settings.serviceNow.severitySelectFieldLabel', - { - defaultMessage: 'Severity', - } -); - -export const IMPACT = i18n.translate( - 'xpack.securitySolution.components.settings.serviceNow.impactSelectFieldLabel', - { - defaultMessage: 'Impact', - } -); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts b/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts deleted file mode 100644 index a5580aaf587b25..00000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { CaseSetting, CaseSettingsRegistry } from './types'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export const createCaseSettingsRegistry = (): CaseSettingsRegistry => { - const settings: Map> = new Map(); - - const registry: CaseSettingsRegistry = { - has: (id: string) => settings.has(id), - register: (setting: CaseSetting) => { - if (settings.has(setting.id)) { - throw new Error( - i18n.translate( - 'xpack.securitySolution.caseSettingsRegistry.register.duplicateCaseSettingErrorMessage', - { - defaultMessage: 'Object type "{id}" is already registered.', - values: { - id: setting.id, - }, - } - ) - ); - } - - settings.set(setting.id, setting); - }, - get: (id: string): CaseSetting => { - if (!settings.has(id)) { - throw new Error( - i18n.translate( - 'xpack.securitySolution.caseSettingsRegistry.get.missingCaseSettingErrorMessage', - { - defaultMessage: 'Object type "{id}" is not registered.', - values: { - id, - }, - } - ) - ); - } - return settings.get(id)!; - }, - list: () => { - return Array.from(settings).map(([id, setting]) => setting); - }, - }; - - return registry; -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/types.ts b/x-pack/plugins/security_solution/public/cases/components/settings/types.ts deleted file mode 100644 index 9f212b1999e3d4..00000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ActionConnector } from '../../../../../case/common/api'; - -import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; -export type CaseSettingsConnector = ActionConnector; - -export interface CaseSetting { - id: string; - caseSettingFieldsComponent: React.LazyExoticComponent< - React.ComponentType> - > | null; -} - -export interface CaseSettingsRegistry { - has: (id: string) => boolean; - register: (setting: CaseSetting) => void; - get: (id: string) => CaseSetting; - list: () => CaseSetting[]; -} - -export interface SettingFieldsProps { - isEdit?: boolean; - connector: CaseSettingsConnector; - fields: TFields; - onChange: (fields: TFields) => void; -} diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index 63838b1bc6b8da..b8048afb083f1c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -39,10 +39,10 @@ jest.mock('../../containers/configure/api'); describe('usePushToService', () => { const caseId = '12345'; const updateCase = jest.fn(); - const postPushToService = jest.fn(); + const pushCaseToExternalService = jest.fn(); const mockPostPush = { isLoading: false, - postPushToService, + pushCaseToExternalService, }; const mockConnector = connectorsMock[0]; @@ -61,7 +61,7 @@ describe('usePushToService', () => { connector: { id: mockConnector.id, name: mockConnector.name, - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, caseId, @@ -71,19 +71,6 @@ describe('usePushToService', () => { updateCase, userCanCrud: true, isValidConnector: true, - alerts: { - 'alert-id-1': { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2020-11-20T15:35:28.373Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: 'now-360s', - to: 'now', - }, - }, - }, }; beforeEach(() => { @@ -105,28 +92,13 @@ describe('usePushToService', () => { ); await waitForNextUpdate(); result.current.pushButton.props.children.props.onClick(); - expect(postPushToService).toBeCalledWith({ + expect(pushCaseToExternalService).toBeCalledWith({ caseId, - caseServices, connector: { fields: null, id: 'servicenow-1', name: 'My Connector', - type: ConnectorTypes.servicenow, - }, - updateCase, - alerts: { - 'alert-id-1': { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2020-11-20T15:35:28.373Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: 'now-360s', - to: 'now', - }, - }, + type: ConnectorTypes.serviceNowITSM, }, }); expect(result.current.pushCallouts).toBeNull(); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx index ed03ce36bf26cb..21067a3e699695 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx @@ -22,7 +22,6 @@ import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; import { ErrorMessage } from '../callout/types'; -import { Alert } from '../case_view'; export interface UsePushToService { caseId: string; @@ -33,7 +32,6 @@ export interface UsePushToService { updateCase: (newCase: Case) => void; userCanCrud: boolean; isValidConnector: boolean; - alerts: Record; } export interface ReturnUsePushToService { @@ -50,25 +48,25 @@ export const usePushToService = ({ updateCase, userCanCrud, isValidConnector, - alerts, }: UsePushToService): ReturnUsePushToService => { const history = useHistory(); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); - const { isLoading, postPushToService } = usePostPushToService(); + const { isLoading, pushCaseToExternalService } = usePostPushToService(); const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); - const handlePushToService = useCallback(() => { + const handlePushToService = useCallback(async () => { if (connector.id != null && connector.id !== 'none') { - postPushToService({ + const theCase = await pushCaseToExternalService({ caseId, - caseServices, connector, - updateCase, - alerts, }); + + if (theCase != null) { + updateCase(theCase); + } } - }, [alerts, caseId, caseServices, connector, postPushToService, updateCase]); + }, [caseId, connector, pushCaseToExternalService, updateCase]); const goToConfigureCases = useCallback( (ev) => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index c5d7610aed9baa..4a567a38dc9f21 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -24,7 +24,7 @@ import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { ActionConnector, CommentType } from '../../../../../case/common/api/cases'; +import { ActionConnector, CommentType } from '../../../../../case/common/api'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { Alert, OnUpdateFields } from '../case_view'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts index 13b9bc670a4fd6..ab761309fa6ad5 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts @@ -25,16 +25,12 @@ import { caseUserActions, pushedCase, respReporters, - serviceConnector, tags, } from '../mock'; import { - CaseExternalServiceRequest, CasePatchRequest, CasePostRequest, CommentRequest, - ServiceConnectorCaseParams, - ServiceConnectorCaseResponse, User, CaseStatuses, } from '../../../../../case/common/api'; @@ -110,15 +106,9 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi export const pushCase = async ( caseId: string, - push: CaseExternalServiceRequest, - signal: AbortSignal -): Promise => Promise.resolve(pushedCase); - -export const pushToService = async ( connectorId: string, - casePushParams: ServiceConnectorCaseParams, signal: AbortSignal -): Promise => Promise.resolve(serviceConnector); +): Promise => Promise.resolve(pushedCase); export const getActionLicense = async (signal: AbortSignal): Promise => Promise.resolve(actionLicenses); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index b3e92f24ce2b30..ee63749b494354 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -25,7 +25,6 @@ import { postCase, postComment, pushCase, - pushToService, } from './api'; import { @@ -34,26 +33,20 @@ import { basicCase, allCasesSnake, basicCaseSnake, - actionTypeExecutorResult, pushedCaseSnake, casesStatus, casesSnake, cases, caseUserActions, pushedCase, - pushSnake, reporters, respReporters, - serviceConnector, - casePushParams, tags, caseUserActionsSnake, casesStatusSnake, } from './mock'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; -import * as i18n from './translations'; -import { getCaseConfigurePushUrl } from '../../../../case/common/api/helpers'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -84,11 +77,13 @@ describe('Case Configuration API', () => { expect(resp).toEqual(''); }); }); + describe('getActionLicense', () => { beforeEach(() => { fetchMock.mockClear(); fetchMock.mockResolvedValue(actionLicenses); }); + test('check url, method, signal', async () => { await getActionLicense(abortCtrl.signal); expect(fetchMock).toHaveBeenCalledWith(`/api/actions/list_action_types`, { @@ -102,6 +97,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(actionLicenses); }); }); + describe('getCase', () => { beforeEach(() => { fetchMock.mockClear(); @@ -123,6 +119,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('getCases', () => { beforeEach(() => { fetchMock.mockClear(); @@ -145,6 +142,7 @@ describe('Case Configuration API', () => { signal: abortCtrl.signal, }); }); + test('correctly applies filters', async () => { await getCases({ filterOptions: { @@ -169,6 +167,7 @@ describe('Case Configuration API', () => { signal: abortCtrl.signal, }); }); + test('tags with weird chars get handled gracefully', async () => { const weirdTags: string[] = ['(', '"double"']; @@ -205,6 +204,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual({ ...allCases }); }); }); + describe('getCasesStatus', () => { beforeEach(() => { fetchMock.mockClear(); @@ -223,6 +223,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(casesStatus); }); }); + describe('getCaseUserActions', () => { beforeEach(() => { fetchMock.mockClear(); @@ -242,6 +243,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(caseUserActions); }); }); + describe('getReporters', () => { beforeEach(() => { fetchMock.mockClear(); @@ -261,6 +263,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(respReporters); }); }); + describe('getTags', () => { beforeEach(() => { fetchMock.mockClear(); @@ -280,6 +283,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(tags); }); }); + describe('patchCase', () => { beforeEach(() => { fetchMock.mockClear(); @@ -307,6 +311,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual({ ...[basicCase] }); }); }); + describe('patchCasesStatus', () => { beforeEach(() => { fetchMock.mockClear(); @@ -334,6 +339,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual({ ...cases }); }); }); + describe('patchComment', () => { beforeEach(() => { fetchMock.mockClear(); @@ -371,6 +377,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('postCase', () => { beforeEach(() => { fetchMock.mockClear(); @@ -405,6 +412,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('postComment', () => { beforeEach(() => { fetchMock.mockClear(); @@ -429,88 +437,30 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('pushCase', () => { + const connectorId = 'connectorId'; + beforeEach(() => { fetchMock.mockClear(); fetchMock.mockResolvedValue(pushedCaseSnake); }); test('check url, method, signal', async () => { - await pushCase(basicCase.id, pushSnake, abortCtrl.signal); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/_push`, { - method: 'POST', - body: JSON.stringify(pushSnake), - signal: abortCtrl.signal, - }); + await pushCase(basicCase.id, connectorId, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_URL}/${basicCase.id}/connector/${connectorId}/_push`, + { + method: 'POST', + body: JSON.stringify({}), + signal: abortCtrl.signal, + } + ); }); test('happy path', async () => { - const resp = await pushCase(basicCase.id, pushSnake, abortCtrl.signal); + const resp = await pushCase(basicCase.id, connectorId, abortCtrl.signal); expect(resp).toEqual(pushedCase); }); }); - describe('pushToService', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(actionTypeExecutorResult); - }); - const connectorId = 'connectorId'; - test('check url, method, signal', async () => { - await pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal); - expect(fetchMock).toHaveBeenCalledWith(`${getCaseConfigurePushUrl(connectorId)}`, { - method: 'POST', - body: JSON.stringify({ - connector_type: ConnectorTypes.jira, - params: casePushParams, - }), - signal: abortCtrl.signal, - }); - }); - - test('happy path', async () => { - const resp = await pushToService( - connectorId, - ConnectorTypes.jira, - casePushParams, - abortCtrl.signal - ); - expect(resp).toEqual(serviceConnector); - }); - - test('unhappy path - serviceMessage', async () => { - const theError = 'the error'; - fetchMock.mockResolvedValue({ - ...actionTypeExecutorResult, - status: 'error', - serviceMessage: theError, - message: 'not it', - }); - await expect( - pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) - ).rejects.toMatchObject({ message: theError }); - }); - - test('unhappy path - message', async () => { - const theError = 'the error'; - fetchMock.mockResolvedValue({ - ...actionTypeExecutorResult, - status: 'error', - message: theError, - }); - await expect( - pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) - ).rejects.toMatchObject({ message: theError }); - }); - - test('unhappy path - no message', async () => { - const theError = i18n.ERROR_PUSH_TO_SERVICE; - fetchMock.mockResolvedValue({ - ...actionTypeExecutorResult, - status: 'error', - }); - await expect( - pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) - ).rejects.toMatchObject({ message: theError }); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 22e6c92da8ceba..00a45aadd2ae08 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -6,7 +6,6 @@ */ import { - CaseExternalServiceRequest, CasePatchRequest, CasePostRequest, CaseResponse, @@ -17,8 +16,6 @@ import { CaseUserActionsResponse, CommentRequest, CommentType, - ServiceConnectorCaseParams, - ServiceConnectorCaseResponse, User, } from '../../../../case/common/api'; @@ -32,7 +29,7 @@ import { import { getCaseCommentsUrl, - getCaseConfigurePushUrl, + getCasePushUrl, getCaseDetailsUrl, getCaseUserActionUrl, } from '../../../../case/common/api/helpers'; @@ -59,10 +56,8 @@ import { decodeCasesFindResponse, decodeCasesStatusResponse, decodeCaseUserActionsResponse, - decodeServiceConnectorCaseResponse, } from './utils'; -import * as i18n from './translations'; -import { ActionTypeExecutorResult } from '../../../../actions/common'; + export const getCase = async ( caseId: string, includeComments: boolean = true, @@ -231,41 +226,19 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi export const pushCase = async ( caseId: string, - push: CaseExternalServiceRequest, + connectorId: string, signal: AbortSignal ): Promise => { const response = await KibanaServices.get().http.fetch( - `${getCaseDetailsUrl(caseId)}/_push`, + getCasePushUrl(caseId, connectorId), { method: 'POST', - body: JSON.stringify(push), + body: JSON.stringify({}), signal, } ); - return convertToCamelCase(decodeCaseResponse(response)); -}; -export const pushToService = async ( - connectorId: string, - connectorType: string, - casePushParams: ServiceConnectorCaseParams, - signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch< - ActionTypeExecutorResult> - >(`${getCaseConfigurePushUrl(connectorId)}`, { - method: 'POST', - body: JSON.stringify({ - connector_type: connectorType, - params: casePushParams, - }), - signal, - }); - - if (response.status === 'error') { - throw new Error(response.serviceMessage ?? response.message ?? i18n.ERROR_PUSH_TO_SERVICE); - } - return decodeServiceConnectorCaseResponse(response.data); + return convertToCamelCase(decodeCaseResponse(response)); }; export const getActionLicense = async (signal: AbortSignal): Promise => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 06983a92b9ea1b..444a87a57d2513 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -9,7 +9,6 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } import { CommentResponse, - ServiceConnectorCaseResponse, CaseStatuses, UserAction, UserActionField, @@ -29,17 +28,13 @@ const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; const laterTime = '2020-02-28T15:02:57.995Z'; + export const elasticUser = { fullName: 'Leslie Knope', username: 'lknope', email: 'leslie.knope@elastic.co', }; -export const serviceConnectorUser = { - fullName: 'Leslie Knope', - username: 'lknope', -}; - export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { @@ -136,19 +131,6 @@ export const pushedCase: Case = { externalService: basicPush, }; -export const serviceConnector: ServiceConnectorCaseResponse = { - title: '123', - id: '444', - pushedDate: basicUpdatedAt, - url: 'connector.com', - comments: [ - { - commentId: basicCommentId, - pushedDate: basicUpdatedAt, - }, - ], -}; - const basicAction = { actionAt: basicCreatedAt, actionBy: elasticUser, @@ -158,25 +140,6 @@ const basicAction = { commentId: null, }; -export const casePushParams = { - savedObjectId: basicCaseId, - createdAt: basicCreatedAt, - createdBy: elasticUser, - externalId: null, - title: 'what a cool value', - commentId: null, - updatedAt: basicCreatedAt, - updatedBy: elasticUser, - description: 'nice', - comments: null, -}; - -export const actionTypeExecutorResult = { - actionId: 'string', - status: 'ok', - data: serviceConnector, -}; - export const cases: Case[] = [ basicCase, { ...pushedCase, id: '1', totalComment: 0, comments: [] }, @@ -192,6 +155,7 @@ export const allCases: AllCases = { total: 10, ...casesStatus, }; + export const actionLicenses: ActionLicense[] = [ { id: '.servicenow', @@ -215,6 +179,7 @@ export const elasticUserSnake = { username: 'lknope', email: 'leslie.knope@elastic.co', }; + export const basicCommentSnake: CommentResponse = { comment: 'Solve this fast!', type: CommentType.user, @@ -260,11 +225,13 @@ export const pushSnake = { external_title: 'external title', external_url: 'basicPush.com', }; + export const basicPushSnake = { ...pushSnake, pushed_at: basicUpdatedAt, pushed_by: elasticUserSnake, }; + export const pushedCaseSnake = { ...basicCaseSnake, external_service: basicPushSnake, diff --git a/x-pack/plugins/security_solution/public/cases/containers/translations.ts b/x-pack/plugins/security_solution/public/cases/containers/translations.ts index 9525d125435e75..75939b46b1f771 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/translations.ts @@ -62,13 +62,6 @@ export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = (serviceName: string) => defaultMessage: 'Successfully sent to { serviceName }', }); -export const ERROR_PUSH_TO_SERVICE = i18n.translate( - 'xpack.securitySolution.case.configure.errorPushingToService', - { - defaultMessage: 'Error pushing to service', - } -); - export const ERROR_GET_FIELDS = i18n.translate( 'xpack.securitySolution.case.configure.errorGetFields', { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx index 8845e285ee910b..5f09ac404ca644 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx @@ -6,112 +6,22 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { - formatServiceRequestData, - usePostPushToService, - UsePostPushToService, -} from './use_post_push_to_service'; -import { - basicCase, - basicComment, - basicPush, - pushedCase, - serviceConnector, - serviceConnectorUser, -} from './mock'; +import { usePostPushToService, UsePostPushToService } from './use_post_push_to_service'; +import { pushedCase } from './mock'; import * as api from './api'; -import { CaseServices } from './use_get_case_user_actions'; -import { CaseConnector, ConnectorTypes, CommentType } from '../../../../case/common/api'; -import moment from 'moment'; +import { CaseConnector, ConnectorTypes } from '../../../../case/common/api'; + jest.mock('./api'); -jest.mock('../../common/components/link_to', () => { - const originalModule = jest.requireActual('../../common/components/link_to'); - return { - ...originalModule, - getTimelineTabsUrl: jest.fn(), - useFormatUrl: jest.fn().mockReturnValue({ formatUrl: jest.fn(), search: 'urlSearch' }), - }; -}); + describe('usePostPushToService', () => { const abortCtrl = new AbortController(); - const updateCase = jest.fn(); - const formatUrl = jest.fn(); - - const samplePush = { - caseId: pushedCase.id, - caseServices: { - '123': { - ...basicPush, - firstPushIndex: 1, - lastPushIndex: 1, - commentsToUpdate: [basicComment.id], - hasDataToPush: false, - }, - }, - connector: { - id: '123', - name: 'connector name', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'Low', parent: null }, - } as CaseConnector, - updateCase, - alerts: { - 'alert-id-1': { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2020-11-20T15:35:28.373Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: 'now-360s', - to: 'now', - }, - }, - }, - }; - - const sampleServiceRequestData = { - savedObjectId: pushedCase.id, - createdAt: pushedCase.createdAt, - createdBy: serviceConnectorUser, - comments: [ - { - commentId: basicComment.id, - comment: basicComment.type === CommentType.user ? 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, - issueType: 'Task', - parent: null, - priority: 'Low', - }; - - const sampleCaseServices = { - '123': { - ...basicPush, - firstPushIndex: 1, - lastPushIndex: 1, - commentsToUpdate: [basicComment.id], - hasDataToPush: true, - }, - '456': { - ...basicPush, - connectorId: '456', - externalId: 'other_external_id', - firstPushIndex: 4, - commentsToUpdate: [basicComment.id], - lastPushIndex: 6, - hasDataToPush: false, - }, - }; + const connector = { + id: '123', + name: 'connector name', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + } as CaseConnector; + const caseId = pushedCase.id; it('init', async () => { await act(async () => { @@ -120,98 +30,24 @@ describe('usePostPushToService', () => { ); await waitForNextUpdate(); expect(result.current).toEqual({ - serviceData: null, - pushedCaseData: null, isLoading: false, isError: false, - postPushToService: result.current.postPushToService, + pushCaseToExternalService: result.current.pushCaseToExternalService, }); }); }); it('calls pushCase with correct arguments', async () => { - const spyOnPushCase = jest.spyOn(api, 'pushCase'); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePostPushToService() - ); - await waitForNextUpdate(); - result.current.postPushToService(samplePush); - await waitForNextUpdate(); - expect(spyOnPushCase).toBeCalledWith( - samplePush.caseId, - { - connector_id: samplePush.connector.id, - connector_name: samplePush.connector.name, - external_id: serviceConnector.id, - external_title: serviceConnector.title, - external_url: serviceConnector.url, - }, - abortCtrl.signal - ); - }); - }); - - it('calls pushToService with correct arguments', async () => { - const spyOnPushToService = jest.spyOn(api, 'pushToService'); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePostPushToService() - ); - await waitForNextUpdate(); - result.current.postPushToService(samplePush); - await waitForNextUpdate(); - expect(spyOnPushToService).toBeCalledWith( - samplePush.connector.id, - samplePush.connector.type, - formatServiceRequestData({ - myCase: basicCase, - connector: samplePush.connector, - caseServices: sampleCaseServices as CaseServices, - alerts: samplePush.alerts, - formatUrl, - }), - abortCtrl.signal - ); - }); - }); - - it('calls pushToService with correct arguments when no push history', async () => { - const samplePush2 = { - caseId: pushedCase.id, - caseServices: {}, - connector: { - name: 'connector name', - id: 'none', - type: ConnectorTypes.none, - fields: null, - }, - alerts: samplePush.alerts, - updateCase, - }; - const spyOnPushToService = jest.spyOn(api, 'pushToService'); + const spyOnPushToService = jest.spyOn(api, 'pushCase'); await act(async () => { const { result, waitForNextUpdate } = renderHook(() => usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush2); + result.current.pushCaseToExternalService({ caseId, connector }); await waitForNextUpdate(); - expect(spyOnPushToService).toBeCalledWith( - samplePush2.connector.id, - samplePush2.connector.type, - formatServiceRequestData({ - myCase: basicCase, - connector: samplePush2.connector, - caseServices: {}, - alerts: samplePush.alerts, - formatUrl, - }), - abortCtrl.signal - ); + expect(spyOnPushToService).toBeCalledWith(caseId, connector.id, abortCtrl.signal); }); }); @@ -221,120 +57,29 @@ describe('usePostPushToService', () => { usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush); + result.current.pushCaseToExternalService({ caseId, connector }); await waitForNextUpdate(); expect(result.current).toEqual({ - serviceData: serviceConnector, - pushedCaseData: pushedCase, isLoading: false, isError: false, - postPushToService: result.current.postPushToService, + pushCaseToExternalService: result.current.pushCaseToExternalService, }); }); }); - it('set isLoading to true when deleting cases', async () => { + it('set isLoading to true when pushing case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush); + result.current.pushCaseToExternalService({ caseId, connector }); expect(result.current.isLoading).toBe(true); }); }); - it('formatServiceRequestData - current connector', () => { - const caseServices = sampleCaseServices; - const result = formatServiceRequestData({ - myCase: pushedCase, - connector: samplePush.connector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - expect(result).toEqual(sampleServiceRequestData); - }); - - it('formatServiceRequestData - connector with history', () => { - const caseServices = sampleCaseServices; - const connector = { - id: '456', - name: 'connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: 'RJ-01' }, - }; - const result = formatServiceRequestData({ - myCase: pushedCase, - connector: connector as CaseConnector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - expect(result).toEqual({ - ...sampleServiceRequestData, - ...connector.fields, - externalId: 'other_external_id', - }); - }); - - it('formatServiceRequestData - new connector', () => { - const caseServices = { - '123': sampleCaseServices['123'], - }; - - const connector = { - id: '456', - name: 'connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - - const result = formatServiceRequestData({ - myCase: pushedCase, - connector: connector as CaseConnector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - - expect(result).toEqual({ - ...sampleServiceRequestData, - ...connector.fields, - externalId: null, - }); - }); - - it('formatServiceRequestData - Alert comment content', () => { - const mockDuration = moment.duration(1); - jest.spyOn(moment, 'duration').mockReturnValue(mockDuration); - formatUrl.mockReturnValue('https://app.com/detections'); - const caseServices = sampleCaseServices; - const result = formatServiceRequestData({ - myCase: { - ...pushedCase, - comments: [ - { - ...pushedCase.comments[0], - type: CommentType.alert, - alertId: 'alert-id-1', - index: 'alert-index-1', - }, - ], - }, - connector: samplePush.connector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - - expect(result.comments![0].comment).toEqual( - '[Alert](https://app.com/detections?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:alert-id-1),type:phrase),query:(match:(_id:(query:alert-id-1,type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%272020-11-20T15:35:28.372Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)),timeline:(linkTo:!(global),timerange:(from:%272020-11-20T15:35:28.372Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)))) added to case.' - ); - }); - it('unhappy path', async () => { - const spyOnPushToService = jest.spyOn(api, 'pushToService'); + const spyOnPushToService = jest.spyOn(api, 'pushCase'); spyOnPushToService.mockImplementation(() => { throw new Error('Something went wrong'); }); @@ -344,15 +89,12 @@ describe('usePostPushToService', () => { usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush); - await waitForNextUpdate(); + result.current.pushCaseToExternalService({ caseId, connector }); expect(result.current).toEqual({ - serviceData: null, - pushedCaseData: null, isLoading: false, isError: true, - postPushToService: result.current.postPushToService, + pushCaseToExternalService: result.current.pushCaseToExternalService, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index c5b4f52e73125d..03d881d7934e91 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -5,41 +5,23 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; -import moment from 'moment'; -import dateMath from '@elastic/datemath'; - -import { - ServiceConnectorCaseResponse, - ServiceConnectorCaseParams, - CaseConnector, - CommentType, -} from '../../../../case/common/api'; -import { SecurityPageName } from '../../app/types'; -import { useFormatUrl, FormatUrl, getRuleDetailsUrl } from '../../common/components/link_to'; +import { useReducer, useCallback, useRef, useEffect } from 'react'; +import { CaseConnector } from '../../../../case/common/api'; import { errorToToaster, useStateToaster, displaySuccessToast, } from '../../common/components/toasters'; -import { Alert } from '../components/case_view'; -import { getCase, pushToService, pushCase } from './api'; +import { pushCase } from './api'; import * as i18n from './translations'; -import { Case, Comment } from './types'; -import { CaseServices } from './use_get_case_user_actions'; +import { Case } from './types'; interface PushToServiceState { - serviceData: ServiceConnectorCaseResponse | null; - pushedCaseData: Case | null; isLoading: boolean; isError: boolean; } -type Action = - | { type: 'FETCH_INIT' } - | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } - | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } - | { type: 'FETCH_FAILURE' }; +type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { switch (action.type) { @@ -49,19 +31,11 @@ const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServ isLoading: true, isError: false, }; - case 'FETCH_SUCCESS_PUSH_SERVICE': - return { - ...state, - isLoading: false, - isError: false, - serviceData: action.payload ?? null, - }; - case 'FETCH_SUCCESS_PUSH_CASE': + case 'FETCH_SUCCESS': return { ...state, isLoading: false, isError: false, - pushedCaseData: action.payload ?? null, }; case 'FETCH_FAILURE': return { @@ -77,72 +51,45 @@ const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServ interface PushToServiceRequest { caseId: string; connector: CaseConnector; - caseServices: CaseServices; - alerts: Record; - updateCase: (newCase: Case) => void; } export interface UsePostPushToService extends PushToServiceState { - postPushToService: ({ + pushCaseToExternalService: ({ caseId, - caseServices, connector, - alerts, - updateCase, - }: PushToServiceRequest) => void; + }: PushToServiceRequest) => Promise; } export const usePostPushToService = (): UsePostPushToService => { const [state, dispatch] = useReducer(dataFetchReducer, { - serviceData: null, - pushedCaseData: null, isLoading: false, isError: false, }); const [, dispatchToaster] = useStateToaster(); - const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const cancel = useRef(false); + const abortCtrl = useRef(new AbortController()); - const postPushToService = useCallback( - async ({ caseId, caseServices, connector, alerts, updateCase }: PushToServiceRequest) => { - let cancel = false; - const abortCtrl = new AbortController(); + const pushCaseToExternalService = useCallback( + async ({ caseId, connector }: PushToServiceRequest) => { try { dispatch({ type: 'FETCH_INIT' }); - const casePushData = await getCase(caseId, true, abortCtrl.signal); - const responseService = await pushToService( - connector.id, - connector.type, - formatServiceRequestData({ - myCase: casePushData, - connector, - caseServices, - alerts, - formatUrl, - }), - abortCtrl.signal - ); - const responseCase = await pushCase( - caseId, - { - connector_id: connector.id, - connector_name: connector.name, - external_id: responseService.id, - external_title: responseService.title, - external_url: responseService.url, - }, - abortCtrl.signal - ); - if (!cancel) { - dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); - dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); - updateCase(responseCase); + abortCtrl.current.abort(); + cancel.current = false; + abortCtrl.current = new AbortController(); + + const response = await pushCase(caseId, connector.id, abortCtrl.current.signal); + + if (!cancel.current) { + dispatch({ type: 'FETCH_SUCCESS' }); displaySuccessToast( i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name), dispatchToaster ); } + + return response; } catch (error) { - if (!cancel) { + if (!cancel.current) { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, @@ -151,123 +98,17 @@ export const usePostPushToService = (): UsePostPushToService => { dispatch({ type: 'FETCH_FAILURE' }); } } - return () => { - cancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); - return { ...state, postPushToService }; -}; - -export const determineToAndFrom = (alert: Alert) => { - const ellapsedTimeRule = moment.duration( - moment().diff(dateMath.parse(alert.rule?.from != null ? alert.rule.from : 'now-0s')) - ); + useEffect(() => { + return () => { + abortCtrl.current.abort(); + cancel.current = true; + }; + }, []); - const from = moment(alert['@timestamp'] ?? new Date()) - .subtract(ellapsedTimeRule) - .toISOString(); - const to = moment(alert['@timestamp'] ?? new Date()).toISOString(); - - return { to, from }; -}; - -const getAlertFilterUrl = (alert: Alert): string => { - const { to, from } = determineToAndFrom(alert); - return `?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:${alert._id}),type:phrase),query:(match:(_id:(query:${alert._id},type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)),timeline:(linkTo:!(global),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`; -}; - -const getCommentContent = ( - comment: Comment, - alerts: Record, - formatUrl: FormatUrl -): string => { - if (comment.type === CommentType.user) { - return comment.comment; - } else if (comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; - const ruleDetailsLink = formatUrl(getRuleDetailsUrl(alert.rule.id), { - absolute: true, - skipSearch: true, - }); - - return `[${i18n.ALERT}](${ruleDetailsLink}${getAlertFilterUrl(alert)}) ${ - i18n.ALERT_ADDED_TO_CASE - }.`; - } - - return ''; -}; - -export const formatServiceRequestData = ({ - myCase, - connector, - caseServices, - alerts, - formatUrl, -}: { - myCase: Case; - connector: CaseConnector; - caseServices: CaseServices; - alerts: Record; - formatUrl: FormatUrl; -}): ServiceConnectorCaseParams => { - const { - id: caseId, - createdAt, - createdBy, - comments, - description, - title, - updatedAt, - updatedBy, - } = myCase; - const actualExternalService = caseServices[connector.id] ?? null; - - return { - savedObjectId: caseId, - createdAt, - createdBy: { - fullName: createdBy.fullName ?? null, - username: createdBy?.username ?? '', - }, - comments: comments - .filter( - (c) => - actualExternalService == null || actualExternalService.commentsToUpdate.includes(c.id) - ) - .map((c) => ({ - commentId: c.id, - comment: getCommentContent(c, alerts, formatUrl), - createdAt: c.createdAt, - createdBy: { - fullName: c.createdBy.fullName ?? null, - username: c.createdBy.username ?? '', - }, - updatedAt: c.updatedAt, - updatedBy: - c.updatedBy != null - ? { - fullName: c.updatedBy.fullName ?? null, - username: c.updatedBy.username ?? '', - } - : null, - })), - description, - externalId: actualExternalService?.externalId ?? null, - title, - ...(connector.fields ?? {}), - updatedAt, - updatedBy: - updatedBy != null - ? { - fullName: updatedBy.fullName ?? null, - username: updatedBy.username ?? '', - } - : null, - }; + return { ...state, pushCaseToExternalService }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.ts b/x-pack/plugins/security_solution/public/cases/containers/utils.ts index 4311390ae9b491..297c7e35981ac1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/utils.ts @@ -26,8 +26,6 @@ import { CaseConfigureResponseRt, CaseUserActionsResponse, CaseUserActionsResponseRt, - ServiceConnectorCaseResponseRt, - ServiceConnectorCaseResponse, CommentType, CasePatchRequest, } from '../../../../case/common/api'; @@ -107,12 +105,6 @@ export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsR fold(throwErrors(createToasterPlainError), identity) ); -export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => - pipe( - ServiceConnectorCaseResponseRt.decode(respPushCase), - fold(throwErrors(createToasterPlainError), identity) - ); - export const valueToUpdateIsSettings = ( key: UpdateByKey['updateKey'], value: UpdateByKey['updateValue'] diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 00d839171eb237..bbb38b503c77a0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17363,7 +17363,6 @@ "xpack.securitySolution.case.components.connectors.case.optionAddToExistingCase": "既存のケースに追加", "xpack.securitySolution.case.components.connectors.case.selectMessageText": "ケースを作成または更新します。", "xpack.securitySolution.case.configure.errorGetFields": "サービスからのフィールドの取得中にエラーが発生しました", - "xpack.securitySolution.case.configure.errorPushingToService": "サービスへのプッシュエラー", "xpack.securitySolution.case.configure.successSaveToast": "保存された外部接続設定", "xpack.securitySolution.case.configureCases.addNewConnector": "新しいコネクターを追加", "xpack.securitySolution.case.configureCases.blankMappings": "1 つ以上のフィールドを { connectorName } にマッピングする必要があります", @@ -17412,14 +17411,6 @@ "xpack.securitySolution.case.pageTitle": "ケース", "xpack.securitySolution.case.readOnlySavedObjectDescription": "ケースを表示する権限のみが付与されています。ケースを開いて更新する必要がある場合は、Kibana管理者に連絡してください。", "xpack.securitySolution.case.readOnlySavedObjectTitle": "新しいケースを開いたり、既存のケースを更新したりすることはできません", - "xpack.securitySolution.case.settings.jira.issueTypesSelectFieldLabel": "問題タイプ", - "xpack.securitySolution.case.settings.jira.parentIssueSearchLabel": "親問題", - "xpack.securitySolution.case.settings.jira.prioritySelectFieldLabel": "優先度", - "xpack.securitySolution.case.settings.resilient.incidentTypesLabel": "インシデントタイプ", - "xpack.securitySolution.case.settings.resilient.incidentTypesPlaceholder": "タイプを選択", - "xpack.securitySolution.case.settings.resilient.severityLabel": "深刻度", - "xpack.securitySolution.case.settings.resilient.unableToGetIncidentTypesMessage": "インシデントタイプを取得できません", - "xpack.securitySolution.case.settings.resilient.unableToGetSeverityMessage": "深刻度を取得できません", "xpack.securitySolution.case.settings.syncAlertsSwitchLabelOff": "オフ", "xpack.securitySolution.case.settings.syncAlertsSwitchLabelOn": "オン", "xpack.securitySolution.case.status.closed": "終了", @@ -17432,8 +17423,6 @@ "xpack.securitySolution.case.timeline.actions.addToCaseAriaLabel": "アラートをケースに関連付ける", "xpack.securitySolution.case.timeline.actions.addToCaseTooltip": "ケースに追加", "xpack.securitySolution.case.timeline.actions.caseCreatedSuccessToast": "アラートが「{title}」に追加されました", - "xpack.securitySolution.caseSettingsRegistry.get.missingCaseSettingErrorMessage": "オブジェクトタイプ「{id}」は登録されていません。", - "xpack.securitySolution.caseSettingsRegistry.register.duplicateCaseSettingErrorMessage": "オブジェクトタイプ「{id}」は既に登録されています。", "xpack.securitySolution.certificate.fingerprint.clientCertLabel": "クライアント証明書", "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "サーバー証明書", "xpack.securitySolution.chart.allOthersGroupingLabel": "その他すべて", @@ -17521,19 +17510,6 @@ "xpack.securitySolution.components.mlPopup.upgradeButtonLabel": "サブスクリプションオプション", "xpack.securitySolution.components.mlPopup.upgradeDescription": "SIEMの異常検出機能にアクセスするには、ライセンスをプラチナに更新するか、30日間の無料トライアルを開始するか、AWS、GCP、またはAzureで{cloudLink}にサインアップしてください。その後、機械学習ジョブを実行して異常を表示できます。", "xpack.securitySolution.components.mlPopup.upgradeTitle": "Elastic Platinum へのアップグレード", - "xpack.securitySolution.components.settings.jira.searchIssuesComboBoxAriaLabel": "入力して検索", - "xpack.securitySolution.components.settings.jira.searchIssuesComboBoxPlaceholder": "入力して検索", - "xpack.securitySolution.components.settings.jira.searchIssuesLoading": "読み込み中…", - "xpack.securitySolution.components.settings.jira.unableToGetFieldsMessage": "フィールドを取得できません", - "xpack.securitySolution.components.settings.jira.unableToGetIssueMessage": "ID {id}の問題を取得できません", - "xpack.securitySolution.components.settings.jira.unableToGetIssuesMessage": "問題を取得できません", - "xpack.securitySolution.components.settings.jira.unableToGetIssueTypesMessage": "問題タイプを取得できません", - "xpack.securitySolution.components.settings.serviceNow.impactSelectFieldLabel": "インパクト", - "xpack.securitySolution.components.settings.serviceNow.severitySelectFieldLabel": "深刻度", - "xpack.securitySolution.components.settings.servicenow.severitySelectHighOptionLabel": "高", - "xpack.securitySolution.components.settings.servicenow.severitySelectLowOptionLabel": "低", - "xpack.securitySolution.components.settings.servicenow.severitySelectMediumOptionLabel": "中", - "xpack.securitySolution.components.settings.serviceNow.urgencySelectFieldLabel": "緊急", "xpack.securitySolution.components.stepDefineRule.ruleTypeField.subscriptionsLink": "プラチナサブスクリプション", "xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "異常データをクエリできませんでした", "xpack.securitySolution.containers.anomalies.stackByJobId": "ジョブ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 38816133d6a241..3583e74c844429 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17407,7 +17407,6 @@ "xpack.securitySolution.case.components.connectors.case.optionAddToExistingCase": "添加到现有案例", "xpack.securitySolution.case.components.connectors.case.selectMessageText": "创建或更新案例。", "xpack.securitySolution.case.configure.errorGetFields": "从服务中获取字段时出错", - "xpack.securitySolution.case.configure.errorPushingToService": "推送到服务时出错", "xpack.securitySolution.case.configure.successSaveToast": "已保存外部连接设置", "xpack.securitySolution.case.configureCases.addNewConnector": "添加新连接器", "xpack.securitySolution.case.configureCases.blankMappings": "至少一个字段需映射到 { connectorName }", @@ -17456,14 +17455,6 @@ "xpack.securitySolution.case.pageTitle": "案例", "xpack.securitySolution.case.readOnlySavedObjectDescription": "您仅有权查看案例。如果需要创建和更新案例,请联系您的 Kibana 管理员。", "xpack.securitySolution.case.readOnlySavedObjectTitle": "您无法创建新案例或更新现有案例", - "xpack.securitySolution.case.settings.jira.issueTypesSelectFieldLabel": "问题类型", - "xpack.securitySolution.case.settings.jira.parentIssueSearchLabel": "父问题", - "xpack.securitySolution.case.settings.jira.prioritySelectFieldLabel": "优先级", - "xpack.securitySolution.case.settings.resilient.incidentTypesLabel": "事件类型", - "xpack.securitySolution.case.settings.resilient.incidentTypesPlaceholder": "选择类型", - "xpack.securitySolution.case.settings.resilient.severityLabel": "严重性", - "xpack.securitySolution.case.settings.resilient.unableToGetIncidentTypesMessage": "无法获取事件类型", - "xpack.securitySolution.case.settings.resilient.unableToGetSeverityMessage": "无法获取严重性", "xpack.securitySolution.case.settings.syncAlertsSwitchLabelOff": "关闭", "xpack.securitySolution.case.settings.syncAlertsSwitchLabelOn": "开启", "xpack.securitySolution.case.status.closed": "已关闭", @@ -17476,8 +17467,6 @@ "xpack.securitySolution.case.timeline.actions.addToCaseAriaLabel": "将告警附加到案例", "xpack.securitySolution.case.timeline.actions.addToCaseTooltip": "添加到案例", "xpack.securitySolution.case.timeline.actions.caseCreatedSuccessToast": "告警已添加到“{title}”", - "xpack.securitySolution.caseSettingsRegistry.get.missingCaseSettingErrorMessage": "对象类型“{id}”未注册。", - "xpack.securitySolution.caseSettingsRegistry.register.duplicateCaseSettingErrorMessage": "已注册对象类型“{id}”。", "xpack.securitySolution.certificate.fingerprint.clientCertLabel": "客户端证书", "xpack.securitySolution.certificate.fingerprint.serverCertLabel": "服务器证书", "xpack.securitySolution.chart.allOthersGroupingLabel": "所有其他", @@ -17565,19 +17554,6 @@ "xpack.securitySolution.components.mlPopup.upgradeButtonLabel": "订阅计划", "xpack.securitySolution.components.mlPopup.upgradeDescription": "要访问 SIEM 的异常检测功能,必须将您的许可证更新到白金级、开始 30 天免费试用或在 AWS、GCP 或 Azure 中实施{cloudLink}。然后便可以运行 Machine Learning 作业并查看异常。", "xpack.securitySolution.components.mlPopup.upgradeTitle": "升级到 Elastic 白金级", - "xpack.securitySolution.components.settings.jira.searchIssuesComboBoxAriaLabel": "键入内容进行搜索", - "xpack.securitySolution.components.settings.jira.searchIssuesComboBoxPlaceholder": "键入内容进行搜索", - "xpack.securitySolution.components.settings.jira.searchIssuesLoading": "正在加载……", - "xpack.securitySolution.components.settings.jira.unableToGetFieldsMessage": "无法获取字段", - "xpack.securitySolution.components.settings.jira.unableToGetIssueMessage": "无法获取 ID 为 {id} 的问题", - "xpack.securitySolution.components.settings.jira.unableToGetIssuesMessage": "无法获取问题", - "xpack.securitySolution.components.settings.jira.unableToGetIssueTypesMessage": "无法获取问题类型", - "xpack.securitySolution.components.settings.serviceNow.impactSelectFieldLabel": "影响", - "xpack.securitySolution.components.settings.serviceNow.severitySelectFieldLabel": "严重性", - "xpack.securitySolution.components.settings.servicenow.severitySelectHighOptionLabel": "高", - "xpack.securitySolution.components.settings.servicenow.severitySelectLowOptionLabel": "低", - "xpack.securitySolution.components.settings.servicenow.severitySelectMediumOptionLabel": "中", - "xpack.securitySolution.components.settings.serviceNow.urgencySelectFieldLabel": "紧急性", "xpack.securitySolution.components.stepDefineRule.ruleTypeField.subscriptionsLink": "白金级订阅", "xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "无法查询异常数据", "xpack.securitySolution.containers.anomalies.stackByJobId": "作业", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts deleted file mode 100644 index df35990da8c0c1..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as i18n from './translations'; -import logo from './logo.svg'; - -export const connectorConfiguration = { - id: '.jira', - name: i18n.JIRA_TITLE, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'gold', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index 26b37278003c35..ba6a5fa2079dca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -11,7 +11,6 @@ import { ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; -import { connectorConfiguration } from './config'; import logo from './logo.svg'; import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types'; import * as i18n from './translations'; @@ -63,10 +62,10 @@ const validateConnector = ( export function getActionType(): ActionTypeModel { return { - id: connectorConfiguration.id, + id: '.jira', iconClass: logo, selectMessage: i18n.JIRA_DESC, - actionTypeTitle: connectorConfiguration.name, + actionTypeTitle: i18n.JIRA_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./jira_connectors')), validateParams: (actionParams: JiraActionParams): GenericValidationResult => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts deleted file mode 100644 index 03b434283cd6e3..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as i18n from './translations'; -import logo from './logo.svg'; - -export const connectorConfiguration = { - id: '.resilient', - name: i18n.TITLE, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index 3e1eafdfebca89..a8fe5e8ae4b6ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -11,7 +11,6 @@ import { ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; -import { connectorConfiguration } from './config'; import logo from './logo.svg'; import { ResilientActionConnector, @@ -72,10 +71,10 @@ export function getActionType(): ActionTypeModel< ResilientActionParams > { return { - id: connectorConfiguration.id, + id: '.resilient', iconClass: logo, selectMessage: i18n.DESC, - actionTypeTitle: connectorConfiguration.name, + actionTypeTitle: i18n.TITLE, validateConnector, actionConnectorFields: lazy(() => import('./resilient_connectors')), validateParams: (actionParams: ResilientActionParams): GenericValidationResult => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts deleted file mode 100644 index 3e629261a29ba3..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as i18n from './translations'; -import logo from './logo.svg'; - -export const serviceNowITSMConfiguration = { - id: '.servicenow', - name: i18n.SERVICENOW_ITSM_TITLE, - desc: i18n.SERVICENOW_ITSM_DESC, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', -}; - -export const serviceNowSIRConfiguration = { - id: '.servicenow-sir', - name: i18n.SERVICENOW_SIR_TITLE, - desc: i18n.SERVICENOW_SIR_DESC, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 82d7f028a3e3df..b1664656c0d14d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -11,7 +11,6 @@ import { ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; -import { serviceNowITSMConfiguration, serviceNowSIRConfiguration } from './config'; import logo from './logo.svg'; import { ServiceNowActionConnector, @@ -68,10 +67,10 @@ export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowITSMActionParams > { return { - id: serviceNowITSMConfiguration.id, + id: '.servicenow', iconClass: logo, - selectMessage: serviceNowITSMConfiguration.desc, - actionTypeTitle: serviceNowITSMConfiguration.name, + selectMessage: i18n.SERVICENOW_ITSM_DESC, + actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), validateParams: ( @@ -103,10 +102,10 @@ export function getServiceNowSIRActionType(): ActionTypeModel< ServiceNowSIRActionParams > { return { - id: serviceNowSIRConfiguration.id, + id: '.servicenow-sir', iconClass: logo, - selectMessage: serviceNowSIRConfiguration.desc, - actionTypeTitle: serviceNowSIRConfiguration.name, + selectMessage: i18n.SERVICENOW_SIR_DESC, + actionTypeTitle: i18n.SERVICENOW_SIR_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index a55811ffa8ffd7..bfc32ef67e46f0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -153,49 +153,22 @@ describe('ServiceNowITSMParamsFields renders', () => { }); }); - test('it transforms the urgencies to options correctly', async () => { + test('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { onChoices(useGetChoicesResponse.choices); }); wrapper.update(); - expect(wrapper.find('[data-test-subj="urgencySelect"]').first().prop('options')).toEqual([ - { value: '1', text: '1 - Critical' }, - { value: '2', text: '2 - High' }, - { value: '3', text: '3 - Moderate' }, - { value: '4', text: '4 - Low' }, - ]); - }); - - test('it transforms the severities to options correctly', async () => { - const wrapper = mount(); - act(() => { - onChoices(useGetChoicesResponse.choices); - }); - - wrapper.update(); - expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([ - { value: '1', text: '1 - Critical' }, - { value: '2', text: '2 - High' }, - { value: '3', text: '3 - Moderate' }, - { value: '4', text: '4 - Low' }, - ]); - }); - - test('it transforms the impacts to options correctly', async () => { - const wrapper = mount(); - act(() => { - onChoices(useGetChoicesResponse.choices); - }); - - wrapper.update(); - expect(wrapper.find('[data-test-subj="impactSelect"]').first().prop('options')).toEqual([ - { value: '1', text: '1 - Critical' }, - { value: '2', text: '2 - High' }, - { value: '3', text: '3 - Moderate' }, - { value: '4', text: '4 - Low' }, - ]); + const testers = ['severity', 'urgency', 'impact']; + testers.forEach((subj) => + expect(wrapper.find(`[data-test-subj="${subj}Select"]`).first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]) + ); }); describe('UI updates', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 1e1ba99633995f..288b6e629112d2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -17,7 +17,7 @@ export const SERVICENOW_ITSM_DESC = i18n.translate( export const SERVICENOW_SIR_DESC = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', { - defaultMessage: 'Create an incident in ServiceNow SIR.', + defaultMessage: 'Create an incident in ServiceNow SecOps.', } ); @@ -31,7 +31,7 @@ export const SERVICENOW_ITSM_TITLE = i18n.translate( export const SERVICENOW_SIR_TITLE = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', { - defaultMessage: 'ServiceNow SIR', + defaultMessage: 'ServiceNow SecOps', } ); @@ -172,7 +172,7 @@ export const MALWARE_URL_LABEL = i18n.translate( export const MALWARE_HASH_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', { - defaultMessage: 'Malware hash', + defaultMessage: 'Malware Hash', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index.ts index f16f1dc1bc1cfd..01470bdddf4d73 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index.ts @@ -11,6 +11,9 @@ export * from './index_controls'; export * from './lib'; export * from './types'; -export { serviceNowITSMConfiguration as ServiceNowITSMConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config'; -export { connectorConfiguration as JiraConnectorConfiguration } from '../application/components/builtin_action_types/jira/config'; -export { connectorConfiguration as ResilientConnectorConfiguration } from '../application/components/builtin_action_types/resilient/config'; +export { + getServiceNowITSMActionType, + getServiceNowSIRActionType, +} from '../application/components/builtin_action_types/servicenow'; +export { getJiraActionType } from '../application/components/builtin_action_types/jira'; +export { getResilientActionType } from '../application/components/builtin_action_types/resilient'; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 7c4ee7b9b0de79..878507bcf4afc3 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -39,6 +39,9 @@ export function getAllExternalServiceSimulatorPaths(): string[] { getExternalServiceSimulatorPath(service) ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); + allPaths.push( + `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident/123` + ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`); allPaths.push( `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index fe891dc6c5f346..ef7c57b3b47499 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -59,7 +59,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); actionsRemover.add('default', connector.id, 'action', 'actions'); - const { body: configure } = await supertest + await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') .send( @@ -70,6 +70,7 @@ export default ({ getService }: FtrProviderContext): void => { }) ) .expect(200); + const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -79,25 +80,34 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: null, impact: null, severity: null }, + fields: { urgency: '2', impact: '2', severity: '2' }, }).connector, }) .expect(200); const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(200); - expect(body.connector.id).to.eql(configure.connector.id); - expect(body.external_service.pushed_by).to.eql(defaultUser); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { pushed_at, external_url, ...rest } = body.external_service; + + expect(rest).to.eql({ + pushed_by: defaultUser, + connector_id: connector.id, + connector_name: connector.name, + external_id: '123', + external_title: 'INC01', + }); + + // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins + expect( + external_url.includes( + 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' + ) + ).to.equal(true); }); it('pushes a comment appropriately', async () => { @@ -112,7 +122,7 @@ export default ({ getService }: FtrProviderContext): void => { actionsRemover.add('default', connector.id, 'action', 'actions'); - const { body: configure } = await supertest + await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') .send( @@ -133,79 +143,134 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: null, impact: null, severity: null }, + fields: { urgency: '2', impact: '2', severity: '2' }, }).connector, }) .expect(200); await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body } = await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(200); + + expect(body.comments[0].pushed_by).to.eql(defaultUser); + }); + + it('should pushes a case and closes when closure_type: close-by-pushing', async () => { + const { body: connector } = await supertest + .post('/api/actions/action') .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, }) .expect(200); + actionsRemover.add('default', connector.id, 'action', 'actions'); await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) + .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(postCommentUserReq) + .send({ + ...getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + }), + closure_type: 'close-by-pushing', + }) .expect(200); - const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + const { body: postedCase } = await supertest + .post(CASES_URL) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', + ...postCaseReq, + connector: getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + fields: { urgency: '2', impact: '2', severity: '2' }, + }).connector, }) .expect(200); - expect(body.comments[0].pushed_by).to.eql(defaultUser); + const { body } = await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(200); + + expect(body.status).to.eql('closed'); }); it('unhappy path - 404s when case does not exist', async () => { await supertest - .post(`${CASES_URL}/fake-id/_push`) + .post(`${CASES_URL}/fake-id/connector/fake-connector/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: 'connector_id', - connector_name: 'connector_name', - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(404); }); - it('unhappy path - 400s when bad data supplied', async () => { - await supertest - .post(`${CASES_URL}/fake-id/_push`) + it('unhappy path - 404s when connector does not exist', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) .set('kbn-xsrf', 'true') .send({ - badKey: 'connector_id', + ...postCaseReq, + connector: getConfiguration().connector, }) - .expect(400); + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/fake-connector/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(404); }); it('unhappy path = 409s when case is closed', async () => { - const { body: configure } = await supertest + const { body: connector } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send({ + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }) + .expect(200); + + actionsRemover.add('default', connector.id, 'action', 'actions'); + + await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration()) + .send( + getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + }) + ) .expect(200); const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq) + .send({ + ...postCaseReq, + connector: getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + fields: { urgency: '2', impact: '2', severity: '2' }, + }).connector, + }) .expect(200); await supertest @@ -223,15 +288,9 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(409); }); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index d0b6ae53cbcd0a..d83d87da1e7afe 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -359,21 +359,15 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: null, impact: null, severity: null }, + fields: { urgency: '2', impact: '2', severity: '2' }, }).connector, }) .expect(200); await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(200); const { body } = await supertest diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 7115576ccccbd5..27a49c3f058695 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -14,8 +14,8 @@ import { } from '../../../../plugins/case/common/api'; export const getConfiguration = ({ - id = 'connector-1', - name = 'Connector 1', + id = 'none', + name = 'none', type = ConnectorTypes.none, fields = null, }: Partial = {}): CasesConfigureRequest => {