From 287541891e7c090016cf1ae64bec0615903bbe65 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 6 Oct 2020 20:03:46 +0300 Subject: [PATCH] [Security Solutions][Case] Settings per case per connector (#77327) Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Steph Milovic Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../builtin_action_types/jira/api.test.ts | 47 +- .../server/builtin_action_types/jira/api.ts | 16 +- .../builtin_action_types/jira/service.ts | 34 +- .../server/builtin_action_types/jira/types.ts | 3 +- .../resilient/api.test.ts | 25 +- .../builtin_action_types/resilient/api.ts | 14 +- .../servicenow/api.test.ts | 52 +- .../builtin_action_types/servicenow/api.ts | 14 +- .../builtin_action_types/servicenow/mocks.ts | 2 +- x-pack/plugins/case/common/api/cases/case.ts | 17 +- .../case/common/api/cases/configure.ts | 26 +- .../case/common/api/cases/user_actions.ts | 2 +- .../case/common/api/connectors/index.ts | 73 + .../case/common/api/connectors/jira.ts | 8 +- .../case/common/api/connectors/resilient.ts | 7 +- .../case/common/api/connectors/servicenow.ts | 8 +- x-pack/plugins/case/common/api/index.ts | 1 + .../__fixtures__/create_mock_so_repository.ts | 6 +- .../server/routes/api/__fixtures__/index.ts | 2 +- .../api/__fixtures__/mock_saved_objects.ts | 55 +- .../routes/api/__mocks__/request_responses.ts | 16 +- .../api/cases/comments/patch_comment.ts | 7 +- .../routes/api/cases/comments/post_comment.ts | 6 +- .../api/cases/configure/get_configure.test.ts | 9 +- .../api/cases/configure/get_configure.ts | 7 +- .../cases/configure/patch_configure.test.ts | 51 +- .../api/cases/configure/patch_configure.ts | 13 +- .../cases/configure/post_configure.test.ts | 150 +- .../api/cases/configure/post_configure.ts | 12 +- .../routes/api/cases/find_cases.test.ts | 12 +- .../server/routes/api/cases/find_cases.ts | 7 +- .../server/routes/api/cases/get_case.test.ts | 46 +- .../case/server/routes/api/cases/get_case.ts | 8 +- .../server/routes/api/cases/helpers.test.ts | 110 + .../case/server/routes/api/cases/helpers.ts | 104 +- .../routes/api/cases/patch_cases.test.ts | 66 +- .../server/routes/api/cases/patch_cases.ts | 31 +- .../server/routes/api/cases/post_case.test.ts | 48 +- .../case/server/routes/api/cases/post_case.ts | 9 +- .../case/server/routes/api/cases/push_case.ts | 16 +- .../case/server/routes/api/utils.test.ts | 185 +- .../plugins/case/server/routes/api/utils.ts | 33 +- .../case/server/saved_object_types/cases.ts | 26 +- .../server/saved_object_types/configure.ts | 29 +- .../server/saved_object_types/migrations.ts | 128 + .../server/saved_object_types/user_actions.ts | 2 + .../case/server/services/configure/index.ts | 14 +- x-pack/plugins/case/server/services/index.ts | 23 +- .../server/services/user_actions/helpers.ts | 60 +- .../cases/components/case_view/index.test.tsx | 104 +- .../cases/components/case_view/index.tsx | 92 +- .../components/case_view/translations.ts | 8 + .../configure_cases/__mock__/index.tsx | 25 +- .../configure_cases/connectors.test.tsx | 4 +- .../components/configure_cases/connectors.tsx | 4 +- .../connectors_dropdown.test.tsx | 4 +- .../configure_cases/connectors_dropdown.tsx | 11 +- .../components/configure_cases/index.test.tsx | 138 +- .../components/configure_cases/index.tsx | 56 +- .../configure_cases/translations.ts | 9 +- .../cases/components/configure_cases/utils.ts | 35 + .../components/connector_selector/form.tsx | 22 +- .../cases/components/create/index.test.tsx | 193 +- .../public/cases/components/create/index.tsx | 297 +- .../public/cases/components/create/schema.tsx | 9 +- .../cases/components/create/translations.ts | 23 + .../components/edit_connector/helpers.ts | 32 + .../components/edit_connector/index.test.tsx | 33 +- .../cases/components/edit_connector/index.tsx | 212 +- .../components/edit_connector/schema.tsx | 13 +- .../components/edit_connector/translations.ts | 16 + .../public/cases/components/settings/card.tsx | 70 + .../cases/components/settings/fields_form.tsx | 60 + .../public/cases/components/settings/index.ts | 47 + .../components/settings/jira/__mocks__/api.ts | 39 + .../components/settings/jira/api.test.ts | 159 + .../cases/components/settings/jira/api.ts | 92 + .../components/settings/jira/fields.test.tsx | 156 + .../cases/components/settings/jira/fields.tsx | 203 ++ .../cases/components/settings/jira/index.ts | 26 + .../settings/jira/search_issues.tsx | 94 + .../components/settings/jira/translations.ts | 76 + .../cases/components/settings/jira/types.ts | 21 + .../use_get_fields_by_issue_type.test.tsx | 104 + .../jira/use_get_fields_by_issue_type.tsx | 92 + .../jira/use_get_issue_types.test.tsx | 106 + .../settings/jira/use_get_issue_types.tsx | 97 + .../settings/jira/use_get_issues.tsx | 94 + .../settings/jira/use_get_single_issue.tsx | 92 + .../public/cases/components/settings/mock.ts | 13 + .../settings/resilient/__mocks__/api.ts | 34 + .../components/settings/resilient/api.ts | 41 + .../settings/resilient/fields.test.tsx | 133 + .../components/settings/resilient/fields.tsx | 186 ++ .../components/settings/resilient/index.ts | 25 + .../settings/resilient/translations.ts | 42 + .../components/settings/resilient/types.ts | 8 + .../resilient/use_get_incident_types.test.tsx | 70 + .../resilient/use_get_incident_types.tsx | 91 + .../resilient/use_get_severity.test.tsx | 76 + .../settings/resilient/use_get_severity.tsx | 88 + .../components/settings/servicenow/fields.tsx | 128 + .../components/settings/servicenow/index.ts | 24 + .../settings/servicenow/translations.ts | 48 + .../components/settings/settings_registry.ts | 56 + .../public/cases/components/settings/types.ts | 32 + .../cases/components/tag_list/index.tsx | 3 +- .../use_push_to_service/index.test.tsx | 46 +- .../components/use_push_to_service/index.tsx | 32 +- .../user_action_tree/helpers.test.tsx | 75 +- .../components/user_action_tree/helpers.tsx | 87 +- .../components/user_action_tree/index.tsx | 81 +- .../cases/components/wrappers/index.tsx | 1 + .../public/cases/containers/api.test.tsx | 7 + .../containers/configure/__mocks__/api.ts | 4 +- .../cases/containers/configure/api.test.ts | 19 +- .../public/cases/containers/configure/api.ts | 4 +- .../public/cases/containers/configure/mock.ts | 39 +- .../cases/containers/configure/types.ts | 17 +- .../configure/use_configure.test.tsx | 29 +- .../containers/configure/use_configure.tsx | 64 +- .../configure/use_connectors.test.tsx | 12 +- .../containers/configure/use_connectors.tsx | 10 +- .../public/cases/containers/mock.ts | 15 +- .../public/cases/containers/types.ts | 6 +- .../public/cases/containers/use_get_case.tsx | 3 +- .../use_get_case_user_actions.test.tsx | 12 +- .../containers/use_get_case_user_actions.tsx | 47 +- .../cases/containers/use_post_case.test.tsx | 7 + .../public/cases/containers/use_post_case.tsx | 2 +- .../use_post_push_to_service.test.tsx | 58 +- .../containers/use_post_push_to_service.tsx | 30 +- .../cases/containers/use_update_case.tsx | 2 +- .../public/cases/translations.ts | 4 + .../components/link_to/redirect_to_case.tsx | 2 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../builtin_action_types/servicenow.ts | 4 + .../basic/tests/cases/find_cases.ts | 1 + .../basic/tests/cases/get_case.ts | 1 + .../basic/tests/cases/migrations.ts | 42 + .../basic/tests/cases/patch_cases.ts | 87 + .../basic/tests/cases/post_case.ts | 38 + .../basic/tests/cases/push_case.ts | 52 +- .../user_actions/get_all_user_actions.ts | 46 +- .../tests/cases/user_actions/migrations.ts | 52 + .../basic/tests/configure/migrations.ts | 42 + .../basic/tests/configure/patch_configure.ts | 32 +- .../basic/tests/configure/post_configure.ts | 20 +- .../case_api_integration/basic/tests/index.ts | 5 + .../case_api_integration/common/lib/mock.ts | 8 +- .../case_api_integration/common/lib/utils.ts | 23 +- .../functional/es_archives/cases/data.json | 139 + .../es_archives/cases/mappings.json | 2556 +++++++++++++++++ 154 files changed, 8612 insertions(+), 959 deletions(-) create mode 100644 x-pack/plugins/case/server/routes/api/cases/helpers.test.ts create mode 100644 x-pack/plugins/case/server/saved_object_types/migrations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/create/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/edit_connector/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/card.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/index.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/api.test.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/index.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/search_issues.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/types.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/mock.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/index.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/types.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/settings/types.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/migrations.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts create mode 100644 x-pack/test/case_api_integration/basic/tests/configure/migrations.ts create mode 100644 x-pack/test/functional/es_archives/cases/data.json create mode 100644 x-pack/test/functional/es_archives/cases/mappings.json diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts index 3948a19d40dae..e8fa9f76df778 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts @@ -74,6 +74,10 @@ describe('api', () => { expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, description: 'Incident description (created at 2020-04-27T10:59:46.202Z by Elastic User)', summary: 'Incident title (created at 2020-04-27T10:59:46.202Z by Elastic User)', @@ -233,6 +237,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', @@ -443,6 +451,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', description: 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', @@ -480,6 +492,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, description: 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', }, @@ -516,6 +532,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, summary: 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', description: @@ -553,7 +573,12 @@ describe('api', () => { }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', - incident: {}, + incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, + }, }); }); @@ -587,6 +612,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', }, }); @@ -622,6 +651,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', @@ -659,6 +692,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', }, @@ -695,6 +732,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, summary: 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', description: @@ -733,6 +774,10 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', + parent: null, summary: 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts index 559bbc047b134..679c1541964ce 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts @@ -91,11 +91,25 @@ const pushToServiceHandler = async ({ defaultPipes, }); - incident = transformFields({ + const transformedFields = transformFields< + PushToServiceApiParams, + ExternalServiceParams, + Incident + >({ params, fields, currentIncident, }); + + const { priority, labels, issueType, parent } = params; + incident = { + summary: transformedFields.summary, + description: transformedFields.description, + priority, + labels, + issueType, + parent, + }; } else { const { title, description, priority, labels, issueType, parent } = params; incident = { summary: title, description, priority, labels, issueType, parent }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index f52d3fa2efd37..f5347891f4f70 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -99,7 +99,21 @@ export const createExternalService = ( return fields; }; - const createErrorMessage = (errors: ResponseError) => { + const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => { + if (errorResponse == null) { + return ''; + } + + const { errorMessages, errors } = errorResponse; + + if (errors == null) { + return ''; + } + + if (Array.isArray(errorMessages) && errorMessages.length > 0) { + return `${errorMessages.join(', ')}`; + } + return Object.entries(errors).reduce((errorMessage, [, value]) => { const msg = errorMessage.length > 0 ? `${errorMessage} ${value}` : value; return msg; @@ -154,7 +168,7 @@ export const createExternalService = ( i18n.NAME, `Unable to get incident with id ${id}. Error: ${ error.message - } Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}` + } Reason: ${createErrorMessage(error.response?.data)}` ) ); } @@ -207,7 +221,7 @@ export const createExternalService = ( getErrorMessage( i18n.NAME, `Unable to create incident. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data?.errors ?? {} + error.response?.data )}` ) ); @@ -249,7 +263,7 @@ export const createExternalService = ( i18n.NAME, `Unable to update incident with id ${incidentId}. Error: ${ error.message - }. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}` + }. Reason: ${createErrorMessage(error.response?.data)}` ) ); } @@ -280,7 +294,7 @@ export const createExternalService = ( i18n.NAME, `Unable to create comment at incident with id ${incidentId}. Error: ${ error.message - }. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}` + }. Reason: ${createErrorMessage(error.response?.data)}` ) ); } @@ -302,7 +316,7 @@ export const createExternalService = ( getErrorMessage( i18n.NAME, `Unable to get capabilities. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data?.errors ?? {} + error.response?.data )}` ) ); @@ -342,7 +356,7 @@ export const createExternalService = ( getErrorMessage( i18n.NAME, `Unable to get issue types. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data?.errors ?? {} + error.response?.data )}` ) ); @@ -388,7 +402,7 @@ export const createExternalService = ( getErrorMessage( i18n.NAME, `Unable to get fields. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data?.errors ?? {} + error.response?.data )}` ) ); @@ -415,7 +429,7 @@ export const createExternalService = ( getErrorMessage( i18n.NAME, `Unable to get issues. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data?.errors ?? {} + error.response?.data )}` ) ); @@ -439,7 +453,7 @@ export const createExternalService = ( getErrorMessage( i18n.NAME, `Unable to get issue with id ${id}. Error: ${error.message}. Reason: ${createErrorMessage( - error.response?.data?.errors ?? {} + error.response?.data )}` ) ); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts index 050ec195d74c1..7d650a22fba1b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -199,5 +199,6 @@ export interface Fields { [key: string]: string | string[] | { name: string } | { key: string } | { id: string }; } export interface ResponseError { - [k: string]: string; + errorMessages: string[] | null | undefined; + errors: { [k: string]: string } | null | undefined; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts index e974fedd0775b..0892a2789bbc0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts @@ -74,6 +74,8 @@ describe('api', () => { expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { + incidentTypes: [1001], + severityCode: 6, description: 'Incident description (created at 2020-06-03T15:09:13.606Z by Elastic User)', name: 'Incident title (created at 2020-06-03T15:09:13.606Z by Elastic User)', @@ -175,6 +177,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, description: 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', @@ -298,6 +302,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', @@ -335,6 +341,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, description: 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, @@ -371,6 +379,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, name: 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: @@ -408,7 +418,10 @@ describe('api', () => { }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', - incident: {}, + incident: { + incidentTypes: [1001], + severityCode: 6, + }, }); }); @@ -442,6 +455,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, }); @@ -477,6 +492,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', @@ -514,6 +531,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, description: 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, @@ -550,6 +569,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, name: 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: @@ -588,6 +609,8 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + incidentTypes: [1001], + severityCode: 6, name: 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts index af3984bf5f0fa..46d9c114297a9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts @@ -73,11 +73,23 @@ const pushToServiceHandler = async ({ defaultPipes, }); - incident = transformFields({ + const transformedFields = transformFields< + PushToServiceApiParams, + ExternalServiceParams, + Incident + >({ params, fields, currentIncident, }); + + const { incidentTypes, severityCode } = params; + incident = { + name: transformedFields.name, + description: transformedFields.description, + incidentTypes, + severityCode, + }; } else { const { title, description, incidentTypes, severityCode } = params; incident = { name: title, description, incidentTypes, severityCode }; 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 7a68781bb9a75..d49c2f265d04f 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 @@ -76,12 +76,16 @@ describe('api', () => { externalService, mapping, params, - secrets: {}, + secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, }); expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { + severity: '1', + urgency: '2', + impact: '3', + caller_id: 'elastic', description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', short_description: @@ -103,6 +107,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledTimes(2); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { incident: { + severity: '1', + urgency: '2', + impact: '3', comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', @@ -114,6 +121,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { incident: { + severity: '1', + urgency: '2', + impact: '3', comments: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', @@ -184,6 +194,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', short_description: @@ -205,6 +218,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledTimes(3); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { incident: { + severity: '1', + urgency: '2', + impact: '3', description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', short_description: @@ -215,6 +231,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { incident: { + severity: '1', + urgency: '2', + impact: '3', comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', @@ -258,6 +277,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', description: @@ -297,6 +319,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', description: 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', }, @@ -334,6 +359,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', short_description: 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', description: @@ -370,9 +398,14 @@ describe('api', () => { secrets: {}, logger: mockedLogger, }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', - incident: {}, + incident: { + severity: '1', + urgency: '2', + impact: '3', + }, }); }); @@ -407,6 +440,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', }, @@ -444,6 +480,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', description: @@ -483,6 +522,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', }, @@ -520,6 +562,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', short_description: 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', description: @@ -559,6 +604,9 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { + severity: '1', + urgency: '2', + impact: '3', short_description: 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', }, 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 455a71517fb4a..6d12a3c92dac7 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 @@ -60,11 +60,23 @@ const pushToServiceHandler = async ({ defaultPipes, }); - incident = transformFields({ + const transformedFields = transformFields< + PushToServiceApiParams, + ExternalServiceParams, + Incident + >({ params, fields, currentIncident, }); + + incident = { + severity: params.severity, + urgency: params.urgency, + impact: params.impact, + short_description: transformedFields.short_description, + description: transformedFields.description, + }; } else { incident = { ...params, short_description: params.title, comments: params.comment }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index f34e9714b22ce..7c2b1bd9d73c1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -75,7 +75,7 @@ const executorParams: ExecutorSubActionPushParams = { comment: 'test-alert comment', severity: '1', urgency: '2', - impact: '1', + impact: '3', comments: [ { commentId: 'case-comment-1', diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 67b296d2ba197..ffeecf27743f5 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -10,6 +10,7 @@ import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; +import { CaseConnectorRt, ESCaseConnector } from '../connectors'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { ActionTypeExecutorResult } from '../../../../actions/server/types'; @@ -17,7 +18,7 @@ export { ActionTypeExecutorResult } from '../../../../actions/server/types'; const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ - connector_id: rt.string, + connector: CaseConnectorRt, description: rt.string, status: StatusRt, tags: rt.array(rt.string), @@ -60,6 +61,7 @@ export const CasePostRequestRt = rt.type({ description: rt.string, tags: rt.array(rt.string), title: rt.string, + connector: CaseConnectorRt, }); export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; @@ -115,6 +117,8 @@ export const CasesResponseRt = rt.array(CaseResponseRt); * so we redefine then so we can use/validate types */ +// TODO: Refactor to support multiple connectors with various fields + const ServiceConnectorUserParams = rt.type({ fullName: rt.union([rt.string, rt.null]), username: rt.string, @@ -130,15 +134,15 @@ export const ServiceConnectorCommentParamsRt = rt.type({ }); export const ServiceConnectorCaseParamsRt = rt.type({ - savedObjectId: rt.string, + 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]), - description: rt.union([rt.string, rt.null]), - comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]), }); export const ServiceConnectorCaseResponseRt = rt.intersection([ @@ -174,3 +178,8 @@ export type ServiceConnectorCaseParams = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; export type ServiceConnectorCommentParams = rt.TypeOf; + +export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; +export type ESCasePatchRequest = Omit & { + connector?: ESCaseConnector; +}; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index 38fff5b190f25..b0a2fa6576fd7 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -8,9 +8,10 @@ import * as rt from 'io-ts'; import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; -import { JiraFieldsRT } from '../connectors/jira'; -import { ServiceNowFieldsRT } from '../connectors/servicenow'; -import { ResilientFieldsRT } from '../connectors/resilient'; +import { JiraCaseFieldsRt } from '../connectors/jira'; +import { ServiceNowCaseFieldsRT } from '../connectors/servicenow'; +import { ResilientCaseFieldsRT } from '../connectors/resilient'; +import { CaseConnectorRt, ESCaseConnector } from '../connectors'; /* * This types below are related to the service now configuration @@ -31,9 +32,9 @@ const CaseFieldRT = rt.union([ ]); const ThirdPartyFieldRT = rt.union([ - JiraFieldsRT, - ServiceNowFieldsRT, - ResilientFieldsRT, + JiraCaseFieldsRt, + ServiceNowCaseFieldsRT, + ResilientCaseFieldsRT, rt.literal('not_mapped'), ]); @@ -62,14 +63,13 @@ export type CasesConnectorConfiguration = rt.TypeOf; - +export type CasesConfigure = rt.TypeOf; export type CasesConfigureRequest = rt.TypeOf; export type CasesConfigurePatch = rt.TypeOf; export type CasesConfigureAttributes = rt.TypeOf; export type CasesConfigureResponse = rt.TypeOf; + +export type ESCasesConfigureAttributes = Omit & { + connector: ESCaseConnector; +}; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 0bed0fd8fc57d..1a3ccfc04eed9 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -14,7 +14,7 @@ import { UserRT } from '../user'; const UserActionFieldRt = rt.array( rt.union([ rt.literal('comment'), - rt.literal('connector_id'), + rt.literal('connector'), rt.literal('description'), rt.literal('pushed'), rt.literal('tags'), diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index 0a7840d3aba22..88d81eed2d87d 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -3,7 +3,80 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; + +import { JiraFieldsRT } from './jira'; +import { ResilientFieldsRT } from './resilient'; +import { ServiceNowFieldsRT } from './servicenow'; export * from './jira'; export * from './servicenow'; export * from './resilient'; + +export const ConnectorFieldsRt = rt.union([ + JiraFieldsRT, + ResilientFieldsRT, + ServiceNowFieldsRT, + rt.null, +]); + +export enum ConnectorTypes { + jira = '.jira', + resilient = '.resilient', + servicenow = '.servicenow', + none = '.none', +} + +const ConnectorJiraTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.jira), + fields: rt.union([JiraFieldsRT, rt.null]), +}); + +const ConnectorResillientTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.resilient), + fields: rt.union([ResilientFieldsRT, rt.null]), +}); + +const ConnectorServiceNowTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.servicenow), + fields: rt.union([ServiceNowFieldsRT, rt.null]), +}); + +const ConnectorNoneTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.none), + fields: rt.null, +}); + +export const ConnectorTypeFieldsRt = rt.union([ + ConnectorJiraTypeFieldsRt, + ConnectorResillientTypeFieldsRt, + ConnectorServiceNowTypeFieldsRt, + ConnectorNoneTypeFieldsRt, +]); + +export const CaseConnectorRt = rt.intersection([ + rt.type({ + id: rt.string, + name: rt.string, + }), + ConnectorTypeFieldsRt, +]); + +export type CaseConnector = rt.TypeOf; +export type ConnectorTypeFields = rt.TypeOf; + +// we need to change these types back and forth for storing in ES (arrays overwrite, objects merge) +export type ConnectorFields = rt.TypeOf; + +export type ESConnectorFields = Array<{ + key: string; + value: unknown; +}>; + +export type ESCaseConnectorTypes = ConnectorTypes; +export interface ESCaseConnector { + id: string; + name: string; + type: ESCaseConnectorTypes; + fields: ESConnectorFields | null; +} diff --git a/x-pack/plugins/case/common/api/connectors/jira.ts b/x-pack/plugins/case/common/api/connectors/jira.ts index 4e4674318ddd8..f6a45d9872fcc 100644 --- a/x-pack/plugins/case/common/api/connectors/jira.ts +++ b/x-pack/plugins/case/common/api/connectors/jira.ts @@ -6,10 +6,16 @@ import * as rt from 'io-ts'; -export const JiraFieldsRT = rt.union([ +export const JiraCaseFieldsRt = rt.union([ rt.literal('summary'), rt.literal('description'), rt.literal('comments'), ]); +export const JiraFieldsRT = rt.type({ + issueType: rt.union([rt.string, rt.null]), + priority: rt.union([rt.string, rt.null]), + parent: rt.union([rt.string, rt.null]), +}); + export type JiraFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts index c7e2f19809140..c2f7beb7626aa 100644 --- a/x-pack/plugins/case/common/api/connectors/resilient.ts +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -6,10 +6,15 @@ import * as rt from 'io-ts'; -export const ResilientFieldsRT = rt.union([ +export const ResilientCaseFieldsRT = rt.union([ rt.literal('name'), rt.literal('description'), rt.literal('comments'), ]); +export const ResilientFieldsRT = rt.type({ + incidentTypes: rt.union([rt.array(rt.string), rt.null]), + severityCode: rt.union([rt.string, rt.null]), +}); + export type ResilientFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow.ts b/x-pack/plugins/case/common/api/connectors/servicenow.ts index fc124bfd46094..efcbeed714210 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow.ts @@ -6,10 +6,16 @@ import * as rt from 'io-ts'; -export const ServiceNowFieldsRT = rt.union([ +export const ServiceNowCaseFieldsRT = rt.union([ rt.literal('short_description'), rt.literal('description'), rt.literal('comments'), ]); +export const ServiceNowFieldsRT = 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; diff --git a/x-pack/plugins/case/common/api/index.ts b/x-pack/plugins/case/common/api/index.ts index fd77f46bef109..665c3b2161b55 100644 --- a/x-pack/plugins/case/common/api/index.ts +++ b/x-pack/plugins/case/common/api/index.ts @@ -5,6 +5,7 @@ */ export * from './cases'; +export * from './connectors'; export * from './runtime_types'; export * from './saved_object'; export * from './user'; 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 eade232593c8e..c2df91148a53a 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 @@ -127,7 +127,7 @@ export const createMockSavedObjectsRepository = ({ if ( type === CASE_CONFIGURE_SAVED_OBJECT && - attributes.connector_id === 'throw-error-create' + attributes.connector.id === 'throw-error-create' ) { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } @@ -151,7 +151,7 @@ export const createMockSavedObjectsRepository = ({ id: 'mock-configuration', attributes, updated_at: '2020-04-09T09:43:51.778Z', - version: attributes.connector_id === 'no-version' ? undefined : 'WzksMV0=', + version: attributes.connector.id === 'no-version' ? undefined : 'WzksMV0=', }; caseConfigureSavedObject = [newConfiguration]; @@ -194,7 +194,7 @@ export const createMockSavedObjectsRepository = ({ type, updated_at: '2019-11-22T22:50:55.191Z', attributes, - version: attributes.connector_id === 'no-version' ? undefined : 'WzE3LDFd', + version: attributes.connector?.id === 'no-version' ? undefined : 'WzE3LDFd', }; } 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 e1fec2d6b229c..2107a457097f4 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { mockCases, mockCasesErrorTriggerData, mockCaseComments } from './mock_saved_objects'; +export * from './mock_saved_objects'; export { createMockSavedObjectsRepository } from './create_mock_so_repository'; export { createRouteContext } from './route_contexts'; export { authenticationMock } from './authc_mock'; 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 135eeecdd491a..265970b1abdec 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 @@ -6,19 +6,25 @@ import { SavedObject } from 'kibana/server'; import { - CaseAttributes, + ESCasesConfigureAttributes, CommentAttributes, - CasesConfigureAttributes, + ESCaseAttributes, + ConnectorTypes, } from '../../../../common/api'; -export const mockCases: Array> = [ +export const mockCases: Array> = [ { type: 'cases', id: 'mock-id-1', attributes: { closed_at: null, closed_by: null, - connector_id: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: [], + }, created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -47,7 +53,12 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - connector_id: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: [], + }, created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -76,7 +87,16 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - connector_id: '123', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }, created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -109,7 +129,16 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - connector_id: '123', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }, created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -134,7 +163,7 @@ export const mockCases: Array> = [ }, ]; -export const mockCaseNoConnectorId: SavedObject> = { +export const mockCaseNoConnectorId: SavedObject> = { type: 'cases', id: 'mock-no-connector_id', attributes: { @@ -266,13 +295,17 @@ export const mockCaseComments: Array> = [ }, ]; -export const mockCaseConfigure: Array> = [ +export const mockCaseConfigure: Array> = [ { type: 'cases-configure', id: 'mock-configuration-1', attributes: { - connector_id: '123', - connector_name: 'My connector', + connector: { + id: '789', + name: 'My connector 3', + type: ConnectorTypes.jira, + fields: null, + }, closure_type: 'close-by-user', created_at: '2020-04-09T09:43:51.778Z', created_by: { 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 b02f53bcd174a..bd276bc91ca3e 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 @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CasePostRequest, CasesConfigureRequest } from '../../../../common/api'; +import { CasePostRequest, CasesConfigureRequest, ConnectorTypes } from '../../../../common/api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../actions/server/types'; @@ -11,6 +11,12 @@ export const newCase: CasePostRequest = { title: 'My new case', description: 'A description', tags: ['new', 'case'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }; export const getActions = (): FindActionResult[] => [ @@ -59,7 +65,11 @@ export const getActions = (): FindActionResult[] => [ ]; export const newConfiguration: CasesConfigureRequest = { - connector_id: '456', - connector_name: 'My connector 2', + connector: { + id: '456', + name: 'My connector 2', + type: ConnectorTypes.jira, + fields: null, + }, closure_type: 'close-by-pushing', }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 85cc63b2f4d17..61d7382fca808 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -16,7 +16,6 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { getConnectorId } from '../helpers'; export function initPatchCommentApi({ caseConfigureService, @@ -71,7 +70,7 @@ export function initPatchCommentApi({ // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase, myCaseConfigure] = await Promise.all([ + const [updatedComment, updatedCase] = await Promise.all([ caseService.patchComment({ client, commentId: query.id, @@ -91,7 +90,6 @@ export function initPatchCommentApi({ }, version: myCase.version, }), - caseConfigureService.find({ client }), ]); const totalCommentsFindByCases = await caseService.getAllCaseComments({ @@ -103,7 +101,7 @@ export function initPatchCommentApi({ perPage: 1, }, }); - const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + const [comments] = await Promise.all([ caseService.getAllCaseComments({ client, @@ -142,7 +140,6 @@ export function initPatchCommentApi({ references: myCase.references, }, comments: comments.saved_objects, - caseConfigureConnectorId, }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index dd6f06777fe98..3c5b72eba5d13 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -16,7 +16,6 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { escapeHatch, transformNewComment, wrapError, flattenCaseSavedObject } from '../../utils'; import { RouteDeps } from '../../types'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -import { getConnectorId } from '../helpers'; export function initPostCommentApi({ caseConfigureService, @@ -52,7 +51,7 @@ export function initPostCommentApi({ const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); - const [newComment, updatedCase, myCaseConfigure] = await Promise.all([ + const [newComment, updatedCase] = await Promise.all([ caseService.postNewComment({ client, attributes: transformNewComment({ @@ -79,10 +78,8 @@ export function initPostCommentApi({ }, version: myCase.version, }), - caseConfigureService.find({ client }), ]); - const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const totalCommentsFindByCases = await caseService.getAllCaseComments({ client, caseId, @@ -130,7 +127,6 @@ export function initPostCommentApi({ references: myCase.references, }, comments: comments.saved_objects, - caseConfigureConnectorId, }) ), }); 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 5b3b6e77b9403..45ce19fca9d20 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 @@ -58,8 +58,12 @@ describe('GET configuration', () => { const res = await routeHandler(context, req, kibanaResponseFactory); expect(res.status).toEqual(200); expect(res.payload).toEqual({ - connector_id: '123', - connector_name: 'My connector', + connector: { + id: '789', + name: 'My connector 3', + type: '.jira', + fields: null, + }, closure_type: 'close-by-user', created_at: '2020-04-09T09:43:51.778Z', created_by: { @@ -91,6 +95,7 @@ describe('GET configuration', () => { const res = await routeHandler(context, req, kibanaResponseFactory); expect(res.status).toEqual(200); + expect(res.payload).toEqual({}); }); 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 5f83e8d6f94f5..9b38524626290 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 @@ -8,6 +8,7 @@ import { CaseConfigureResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { transformESConnectorToCaseConnector } from '../helpers'; export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) { router.get( @@ -21,11 +22,15 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps const myCaseConfigure = await caseConfigureService.find({ client }); + const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0] + ?.attributes ?? { connector: null }; + return response.ok({ body: myCaseConfigure.saved_objects.length > 0 ? CaseConfigureResponseRt.encode({ - ...myCaseConfigure.saved_objects[0].attributes, + ...caseConfigureWithoutConnector, + connector: transformESConnectorToCaseConnector(connector), version: myCaseConfigure.saved_objects[0].version ?? '', }) : {}, 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 9b71f777b95ab..8fcb769225d44 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 @@ -16,6 +16,7 @@ import { import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initPatchCaseConfigure } from './patch_configure'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { ConnectorTypes } from '../../../../../common/api/connectors'; describe('PATCH configuration', () => { let routeHandler: RequestHandler; @@ -50,6 +51,7 @@ describe('PATCH configuration', () => { expect(res.payload).toEqual( expect.objectContaining({ ...mockCaseConfigure[0].attributes, + connector: { fields: null, id: '789', name: 'My connector 3', type: '.jira' }, closure_type: 'close-by-pushing', updated_at: '2020-04-09T09:43:51.778Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, @@ -82,6 +84,7 @@ describe('PATCH configuration', () => { expect(res.payload).toEqual( expect.objectContaining({ ...mockCaseConfigure[0].attributes, + connector: { fields: null, id: '789', name: 'My connector 3', type: '.jira' }, closure_type: 'close-by-pushing', updated_at: '2020-04-09T09:43:51.778Z', updated_by: { email: null, full_name: null, username: null }, @@ -90,6 +93,44 @@ describe('PATCH configuration', () => { ); }); + it('patch configuration - connector', async () => { + routeHandler = await createRoute(initPatchCaseConfigure, 'patch'); + + const req = httpServerMock.createKibanaRequest({ + path: CASE_CONFIGURE_URL, + method: 'patch', + body: { + connector: { + id: 'connector-new', + name: 'New connector', + type: '.jira', + fields: null, + }, + version: mockCaseConfigure[0].version, + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + + expect(res.status).toEqual(200); + expect(res.payload).toEqual( + expect.objectContaining({ + ...mockCaseConfigure[0].attributes, + connector: { id: 'connector-new', name: 'New connector', type: '.jira', fields: null }, + closure_type: 'close-by-user', + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }) + ); + }); + it('throw error when configuration have not being created', async () => { const req = httpServerMock.createKibanaRequest({ path: CASE_CONFIGURE_URL, @@ -138,7 +179,15 @@ describe('PATCH configuration', () => { const req = httpServerMock.createKibanaRequest({ path: CASE_CONFIGURE_URL, method: 'patch', - body: { connector_id: 'no-version', version: mockCaseConfigure[0].version }, + body: { + connector: { + id: 'no-version', + name: 'no version', + type: ConnectorTypes.none, + fields: null, + }, + version: mockCaseConfigure[0].version, + }, }); const context = createRouteContext( 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 06c99c8018cc0..d3a64559e5a2b 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 @@ -17,6 +17,10 @@ import { import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, +} from '../helpers'; export function initPatchCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { router.patch( @@ -35,8 +39,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout ); const myCaseConfigure = await caseConfigureService.find({ client }); - const { version, ...queryWithoutVersion } = query; - + const { version, connector, ...queryWithoutVersion } = query; if (myCaseConfigure.saved_objects.length === 0) { throw Boom.conflict( 'You can not patch this configuration since you did not created first with a post.' @@ -58,6 +61,9 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout caseConfigureId: myCaseConfigure.saved_objects[0].id, updatedAttributes: { ...queryWithoutVersion, + ...(connector != null + ? { connector: transformCaseConnectorToEsConnector(connector) } + : {}), updated_at: updateDate, updated_by: { email, full_name, username }, }, @@ -67,6 +73,9 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout body: CaseConfigureResponseRt.encode({ ...myCaseConfigure.saved_objects[0].attributes, ...patch.attributes, + connector: transformESConnectorToCaseConnector( + patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector + ), version: patch.version ?? '', }), }); 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 fb95cc53a1710..27df19d8f823a 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 @@ -17,6 +17,7 @@ import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; import { initPostCaseConfigure } from './post_configure'; import { newConfiguration } from '../../__mocks__/request_responses'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { ConnectorTypes } from '../../../../../common/api/connectors'; describe('POST configuration', () => { let routeHandler: RequestHandler; @@ -47,8 +48,12 @@ describe('POST configuration', () => { expect(res.status).toEqual(200); expect(res.payload).toEqual( expect.objectContaining({ - connector_id: '456', - connector_name: 'My connector 2', + connector: { + id: '456', + name: 'My connector 2', + type: '.jira', + fields: null, + }, closure_type: 'close-by-pushing', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, @@ -78,8 +83,12 @@ describe('POST configuration', () => { expect(res.status).toEqual(200); expect(res.payload).toEqual( expect.objectContaining({ - connector_id: '456', - connector_name: 'My connector 2', + connector: { + id: '456', + name: 'My connector 2', + type: '.jira', + fields: null, + }, closure_type: 'close-by-pushing', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: null, full_name: null, username: null }, @@ -89,12 +98,16 @@ describe('POST configuration', () => { ); }); - it('throws when missing connector_id', async () => { + it('throws when missing connector.id', async () => { const req = httpServerMock.createKibanaRequest({ path: CASE_CONFIGURE_URL, method: 'post', body: { - connector_name: 'My connector 2', + connector: { + name: 'My connector 2', + type: '.jira', + fields: null, + }, closure_type: 'close-by-pushing', }, }); @@ -110,12 +123,66 @@ describe('POST configuration', () => { expect(res.payload.isBoom).toEqual(true); }); - it('throws when missing connector_name', async () => { + it('throws when missing connector.name', async () => { const req = httpServerMock.createKibanaRequest({ path: CASE_CONFIGURE_URL, method: 'post', body: { - connector_id: '456', + connector: { + id: '456', + type: '.jira', + fields: null, + }, + closure_type: 'close-by-pushing', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('throws when missing connector.type', async () => { + const req = httpServerMock.createKibanaRequest({ + path: CASE_CONFIGURE_URL, + method: 'post', + body: { + connector: { + id: '456', + name: 'My connector 2', + fields: null, + }, + closure_type: 'close-by-pushing', + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('throws when missing connector.fields', async () => { + const req = httpServerMock.createKibanaRequest({ + path: CASE_CONFIGURE_URL, + method: 'post', + body: { + connector: { + id: '456', + name: 'My connector 2', + type: ConnectorTypes.none, + }, closure_type: 'close-by-pushing', }, }); @@ -136,8 +203,12 @@ describe('POST configuration', () => { path: CASE_CONFIGURE_URL, method: 'post', body: { - connector_id: '456', - connector_name: 'My connector 2', + connector: { + id: '456', + name: 'My connector 2', + type: '.jira', + fields: null, + }, }, }); @@ -254,8 +325,11 @@ describe('POST configuration', () => { path: CASE_CONFIGURE_URL, method: 'post', body: { - connector_id: 'throw-error-create', - connector_name: 'My connector 2', + connector: { + id: 'throw-error-create', + name: 'My connector 2', + fields: null, + }, closure_type: 'close-by-pushing', }, }); @@ -275,7 +349,15 @@ describe('POST configuration', () => { const req = httpServerMock.createKibanaRequest({ path: CASE_CONFIGURE_URL, method: 'post', - body: { ...newConfiguration, connector_id: 'no-version' }, + body: { + ...newConfiguration, + connector: { + id: 'no-version', + name: 'no version', + type: ConnectorTypes.none, + fields: null, + }, + }, }); const context = createRouteContext( @@ -292,4 +374,46 @@ describe('POST configuration', () => { }) ); }); + + it('returns an error if fields are not null', async () => { + const req = httpServerMock.createKibanaRequest({ + path: CASE_CONFIGURE_URL, + method: 'post', + body: { + ...newConfiguration, + connector: { id: 'not-null', name: 'not-null', type: ConnectorTypes.none, fields: {} }, + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); + + it('returns an error if the type of the connector does not exists', async () => { + const req = httpServerMock.createKibanaRequest({ + path: CASE_CONFIGURE_URL, + method: 'post', + body: { + ...newConfiguration, + connector: { id: 'not-exists', name: 'not-exist', type: '.not-exists', fields: null }, + }, + }); + + const context = createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload.isBoom).toEqual(true); + }); }); 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 3f02809cbd08f..48b4582b2447b 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 @@ -17,6 +17,10 @@ import { import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, +} from '../helpers'; export function initPostCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { router.post( @@ -51,6 +55,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route client, attributes: { ...query, + connector: transformCaseConnectorToEsConnector(query.connector), created_at: creationDate, created_by: { email, full_name, username }, updated_at: null, @@ -59,7 +64,12 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route }); return response.ok({ - body: CaseConfigureResponseRt.encode({ ...post.attributes, version: post.version ?? '' }), + body: CaseConfigureResponseRt.encode({ + ...post.attributes, + // Reserve for future implementations + connector: transformESConnectorToCaseConnector(post.attributes.connector), + version: post.version ?? '', + }), }); } catch (error) { return response.customError(wrapError(error)); 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 9adb1eeb1bca0..df27551d2c922 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 @@ -22,6 +22,7 @@ describe('FIND all cases', () => { beforeAll(async () => { routeHandler = await createRoute(initFindCasesApi, 'get'); }); + it(`gets all the cases`, async () => { const request = httpServerMock.createKibanaRequest({ path: `${CASES_URL}/_find`, @@ -38,7 +39,8 @@ describe('FIND all cases', () => { expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); }); - it(`has proper connector id on cases with configured id`, async () => { + + it(`has proper connector id on cases with configured connector`, async () => { const request = httpServerMock.createKibanaRequest({ path: `${CASES_URL}/_find`, method: 'get', @@ -52,8 +54,9 @@ describe('FIND all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases[2].connector_id).toEqual('123'); + expect(response.payload.cases[2].connector.id).toEqual('123'); }); + it(`adds 'none' connector id to cases without when 3rd party unconfigured`, async () => { const request = httpServerMock.createKibanaRequest({ path: `${CASES_URL}/_find`, @@ -68,8 +71,9 @@ describe('FIND all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases[0].connector_id).toEqual('none'); + expect(response.payload.cases[0].connector.id).toEqual('none'); }); + it(`adds default connector id to cases without when 3rd party configured`, async () => { const request = httpServerMock.createKibanaRequest({ path: `${CASES_URL}/_find`, @@ -85,6 +89,6 @@ describe('FIND all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases[0].connector_id).toEqual('123'); + expect(response.payload.cases[0].connector.id).toEqual('none'); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index db57315388c5e..4cdafca1cc9e7 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -16,7 +16,6 @@ import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; -import { getConnectorId } from './helpers'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter((i) => i !== '').join(` ${operator} `); @@ -95,11 +94,10 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: filter: getStatusFilter('closed', myFilters), }, }; - const [cases, openCases, closesCases, myCaseConfigure] = await Promise.all([ + const [cases, openCases, closesCases] = await Promise.all([ caseService.findCases(args), caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), - caseConfigureService.find({ client }), ]); const totalCommentsFindByCases = await Promise.all( cases.saved_objects.map((c) => @@ -136,8 +134,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: cases, openCases.total ?? 0, closesCases.total ?? 0, - totalCommentsByCases, - getConnectorId(myCaseConfigure) + totalCommentsByCases ) ), }); 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 ed291c2cbf726..224da4464e1c2 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 @@ -7,7 +7,7 @@ import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; -import { CaseAttributes } from '../../../../common/api'; +import { ConnectorTypes, ESCaseAttributes } from '../../../../common/api'; import { createMockSavedObjectsRepository, createRoute, @@ -15,11 +15,12 @@ import { mockCases, mockCasesErrorTriggerData, mockCaseComments, + mockCaseNoConnectorId, + mockCaseConfigure, } from '../__fixtures__'; import { flattenCaseSavedObject } from '../utils'; import { initGetCaseApi } from './get_case'; import { CASE_DETAILS_URL } from '../../../../common/constants'; -import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('GET case', () => { let routeHandler: RequestHandler; @@ -46,12 +47,17 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); const savedObject = (mockCases.find((s) => s.id === 'mock-id-1') as unknown) as SavedObject< - CaseAttributes + ESCaseAttributes >; expect(response.status).toEqual(200); - expect(response.payload).toEqual(flattenCaseSavedObject({ savedObject })); + expect(response.payload).toEqual( + flattenCaseSavedObject({ + savedObject, + }) + ); expect(response.payload.comments).toEqual([]); }); + it(`returns an error when thrown from getCase`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_DETAILS_URL, @@ -75,6 +81,7 @@ describe('GET case', () => { expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); + it(`returns the case with case comments when includeComments is true`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_DETAILS_URL, @@ -99,6 +106,7 @@ describe('GET case', () => { expect(response.status).toEqual(200); expect(response.payload.comments).toHaveLength(3); }); + it(`returns an error when thrown from getAllCaseComments`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_DETAILS_URL, @@ -121,7 +129,8 @@ describe('GET case', () => { expect(response.status).toEqual(400); }); - it(`case w/o connector_id - returns the case with connector id when 3rd party unconfigured`, async () => { + + it(`case w/o connector.id - returns the case with connector id when 3rd party unconfigured`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_DETAILS_URL, method: 'get', @@ -142,9 +151,15 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.connector_id).toEqual('none'); + expect(response.payload.connector).toEqual({ + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }); }); - it(`case w/o connector_id - returns the case with connector id when 3rd party configured`, async () => { + + it(`case w/o connector.id - returns the case with connector id when 3rd party configured`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_DETAILS_URL, method: 'get', @@ -166,9 +181,15 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.connector_id).toEqual('123'); + expect(response.payload.connector).toEqual({ + fields: null, + id: 'none', + name: 'none', + type: '.none', + }); }); - it(`case w/ connector_id - returns the case with connector id when case already has connectorId`, async () => { + + it(`case w/ connector.id - returns the case with connector id when case already has connectorId`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASE_DETAILS_URL, method: 'get', @@ -190,6 +211,11 @@ describe('GET case', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.connector_id).toEqual('123'); + expect(response.payload.connector).toEqual({ + fields: { issueType: 'Task', priority: 'High', parent: null }, + id: '123', + name: 'My connector', + type: '.jira', + }); }); }); 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 57b472d3889cc..973beacc10f7c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -10,7 +10,6 @@ import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { flattenCaseSavedObject, wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; -import { getConnectorId } from './helpers'; export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { router.get( @@ -30,22 +29,18 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); - const [theCase, myCaseConfigure] = await Promise.all([ + const [theCase] = await Promise.all([ caseService.getCase({ client, caseId: request.params.case_id, }), - caseConfigureService.find({ client }), ]); - const caseConfigureConnectorId = getConnectorId(myCaseConfigure); - if (!includeComments) { return response.ok({ body: CaseResponseRt.encode( flattenCaseSavedObject({ savedObject: theCase, - caseConfigureConnectorId, }) ), }); @@ -66,7 +61,6 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro savedObject: theCase, comments: theComments.saved_objects, totalComment: theComments.total, - caseConfigureConnectorId, }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.test.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.test.ts new file mode 100644 index 0000000000000..e9e5b69225a62 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsFindResponse } from 'kibana/server'; +import { + CaseConnector, + ConnectorTypes, + ESCaseConnector, + ESCasesConfigureAttributes, +} from '../../../../common/api'; +import { mockCaseConfigure } from '../__fixtures__'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, + getConnectorFromConfiguration, +} from './helpers'; + +describe('helpers', () => { + const caseConnector: CaseConnector = { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + + const esCaseConnector: ESCaseConnector = { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }; + + const caseConfigure: SavedObjectsFindResponse = { + saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], + total: 1, + per_page: 20, + page: 1, + }; + + describe('transformCaseConnectorToEsConnector', () => { + it('transform correctly', () => { + expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); + }); + + it('transform correctly with null attributes', () => { + // @ts-ignore this is case the connector does not exist for old cases object or configurations + expect(transformCaseConnectorToEsConnector(null)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: [], + }); + }); + }); + + describe('transformESConnectorToCaseConnector', () => { + it('transform correctly', () => { + expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); + }); + + it('transform correctly with null attributes', () => { + // @ts-ignore this is case the connector does not exist for old cases object or configurations + expect(transformESConnectorToCaseConnector(null)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); + }); + }); + + describe('getConnectorFromConfiguration', () => { + it('transform correctly', () => { + expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ + id: '789', + name: 'My connector 3', + type: ConnectorTypes.jira, + fields: null, + }); + }); + + it('transform correctly with no connector', () => { + const caseConfigureNoConnector: SavedObjectsFindResponse = { + ...caseConfigure, + saved_objects: [ + { + ...mockCaseConfigure[0], + // @ts-ignore this is case the connector does not exist for old cases object or configurations + attributes: { ...mockCaseConfigure[0].attributes, connector: null }, + score: 0, + }, + ], + }; + + expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); + }); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 78b108b00d4a7..b71dc013a1e5b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -4,10 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; +import { get, isPlainObject } from 'lodash'; +import deepEqual from 'fast-deep-equal'; import { SavedObjectsFindResponse } from 'kibana/server'; -import { CaseAttributes, CasePatchRequest, CasesConfigureAttributes } from '../../../../common/api'; +import { + CaseConnector, + ESCaseConnector, + ESCaseAttributes, + ESCasePatchRequest, + ESCasesConfigureAttributes, + ConnectorTypes, +} from '../../../../common/api'; +import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; interface CompareArrays { addedItems: string[]; @@ -57,9 +66,9 @@ export const isTwoArraysDifference = ( }; export const getCaseToUpdate = ( - currentCase: CaseAttributes, - queryCase: CasePatchRequest -): CasePatchRequest => + currentCase: ESCaseAttributes, + queryCase: ESCasePatchRequest +): ESCasePatchRequest => Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); @@ -70,26 +79,89 @@ export const getCaseToUpdate = ( [key]: value, }; } + return acc; + } else if (isPlainObject(currentValue) && isPlainObject(value)) { + if (!deepEqual(currentValue, value)) { + return { + ...acc, + [key]: value, + }; + } + return acc; } else if (currentValue != null && value !== currentValue) { return { ...acc, [key]: value, }; - } else if (currentValue == null && key === 'connector_id' && value !== currentValue) { - return { - ...acc, - [key]: value, - }; } return acc; }, { id: queryCase.id, version: queryCase.version } ); -export const getConnectorId = ( - caseConfigure: SavedObjectsFindResponse -): string => - caseConfigure.saved_objects.length > 0 - ? caseConfigure.saved_objects[0].attributes.connector_id - : 'none'; +export const getNoneCaseConnector = () => ({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, +}); + +export const getConnectorFromConfiguration = ( + caseConfigure: SavedObjectsFindResponse +): CaseConnector => { + let caseConnector = getNoneCaseConnector(); + if ( + caseConfigure.saved_objects.length > 0 && + caseConfigure.saved_objects[0].attributes.connector + ) { + caseConnector = { + id: caseConfigure.saved_objects[0].attributes.connector.id, + name: caseConfigure.saved_objects[0].attributes.connector.name, + type: caseConfigure.saved_objects[0].attributes.connector.type, + fields: null, + }; + } + return caseConnector; +}; + +export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ + id: connector?.id ?? 'none', + name: connector?.name ?? 'none', + type: connector?.type ?? '.none', + fields: + connector?.fields != null + ? Object.entries(connector.fields).reduce( + (acc, [key, value]) => [ + ...acc, + { + key, + value, + }, + ], + [] + ) + : [], +}); + +export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { + const connectorTypeField = { + type: connector?.type ?? '.none', + fields: + connector && connector.fields != null && connector.fields.length > 0 + ? connector.fields.reduce( + (fields, { key, value }) => ({ + ...fields, + [key]: value, + }), + {} + ) + : null, + } as ConnectorTypeFields; + + return { + id: connector?.id ?? 'none', + name: connector?.name ?? 'none', + ...connectorTypeField, + }; +}; 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 b5100907f246a..c0d19edcad91f 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 @@ -16,6 +16,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; +import { ConnectorTypes } from '../../../../common/api/connectors'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -26,6 +27,7 @@ describe('PATCH cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); + it(`Close a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', @@ -54,7 +56,12 @@ describe('PATCH cases', () => { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comments: [], - connector_id: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', @@ -70,6 +77,7 @@ describe('PATCH cases', () => { }, ]); }); + it(`Open a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', @@ -99,7 +107,12 @@ describe('PATCH cases', () => { closed_at: null, closed_by: null, comments: [], - connector_id: '123', + connector: { + id: '123', + name: 'My connector', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -115,7 +128,8 @@ describe('PATCH cases', () => { }, ]); }); - it(`Patches a case without a connector_id`, async () => { + + it(`Patches a case without a connector.id`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -138,9 +152,10 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload[0].connector_id).toEqual('none'); + expect(response.payload[0].connector.id).toEqual('none'); }); - it(`Patches a case with a connector_id`, async () => { + + it(`Patches a case with a connector.id`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -163,8 +178,45 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload[0].connector_id).toEqual('123'); + expect(response.payload[0].connector.id).toEqual('123'); }); + + it(`Change connector`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-3', + connector: { + id: '456', + name: 'My connector 2', + type: '.jira', + fields: { issueType: 'Bug', priority: 'Low', parent: null }, + }, + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector).toEqual({ + id: '456', + name: 'My connector 2', + type: '.jira', + fields: { issueType: 'Bug', priority: 'Low', parent: null }, + }); + }); + it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', @@ -189,6 +241,7 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); + it(`Fails with 406 if updated field is unchanged`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', @@ -214,6 +267,7 @@ describe('PATCH cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(406); }); + it(`Returns an error if updateCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index b70177b47ec97..79e2e99731546 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -15,10 +15,11 @@ import { CasePatchRequest, excess, throwErrors, + ESCasePatchRequest, } from '../../../../common/api'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; -import { getCaseToUpdate, getConnectorId } from './helpers'; +import { getCaseToUpdate, transformCaseConnectorToEsConnector } from './helpers'; import { buildCaseUserActions } from '../../../services/user_actions/helpers'; import { CASES_URL } from '../../../../common/constants'; @@ -43,14 +44,10 @@ export function initPatchCasesApi({ fold(throwErrors(Boom.badRequest), identity) ); - const [myCases, myCaseConfigure] = await Promise.all([ - caseService.getCases({ - client, - caseIds: query.cases.map((q) => q.id), - }), - caseConfigureService.find({ client }), - ]); - const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + const myCases = await caseService.getCases({ + client, + caseIds: query.cases.map((q) => q.id), + }); let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter((q) => { @@ -76,16 +73,25 @@ export function initPatchCasesApi({ .join(', ')} has been updated. Please refresh before saving additional updates.` ); } - const updateCases: CasePatchRequest[] = query.cases.map((thisCase) => { - const currentCase = myCases.saved_objects.find((c) => c.id === thisCase.id); + + const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { + const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); + const { connector, ...thisCase } = updateCase; return currentCase != null - ? getCaseToUpdate(currentCase.attributes, thisCase) + ? getCaseToUpdate(currentCase.attributes, { + ...thisCase, + ...(connector != null + ? { connector: transformCaseConnectorToEsConnector(connector) } + : {}), + }) : { id: thisCase.id, version: thisCase.version }; }); + const updateFilterCases = updateCases.filter((updateCase) => { const { id, version, ...updateCaseAttributes } = updateCase; return Object.keys(updateCaseAttributes).length > 0; }); + if (updateFilterCases.length > 0) { // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request, response }); @@ -133,7 +139,6 @@ export function initPatchCasesApi({ references: myCase.references, version: updatedCase?.version ?? myCase.version, }, - caseConfigureConnectorId, }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index b545eb8b7fb08..be1ed4166ab7b 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 @@ -16,6 +16,7 @@ import { import { initPostCaseApi } from './post_case'; import { CASES_URL } from '../../../../common/constants'; import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; +import { ConnectorTypes } from '../../../../common/api/connectors'; describe('POST cases', () => { let routeHandler: RequestHandler; @@ -26,6 +27,7 @@ describe('POST cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); + it(`Posts a new case, no connector configured`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASES_URL, @@ -34,6 +36,12 @@ describe('POST cases', () => { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }, }); @@ -47,9 +55,15 @@ describe('POST cases', () => { expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); expect(response.payload.created_by.username).toEqual('awesome'); - expect(response.payload.connector_id).toEqual('none'); + expect(response.payload.connector).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); }); - it(`Posts a new case, connector configured`, async () => { + + it(`Posts a new case, connector provided`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASES_URL, method: 'post', @@ -57,6 +71,12 @@ describe('POST cases', () => { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + connector: { + id: '123', + name: 'Jira', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, }, }); @@ -69,7 +89,12 @@ describe('POST cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.connector_id).toEqual('123'); + expect(response.payload.connector).toEqual({ + id: '123', + name: 'Jira', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }); }); it(`Error if you passing status for a new case`, async () => { @@ -81,6 +106,7 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', status: 'open', tags: ['defacement'], + connector: null, }, }); @@ -93,6 +119,7 @@ describe('POST cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); + it(`Returns an error if postNewCase throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASES_URL, @@ -101,6 +128,7 @@ describe('POST cases', () => { description: 'Throw an error', title: 'Super Bad Security Issue', tags: ['error'], + connector: null, }, }); @@ -114,6 +142,7 @@ describe('POST cases', () => { expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); + it(`Allow user to create case without authentication`, async () => { routeHandler = await createRoute(initPostCaseApi, 'post', true); @@ -124,6 +153,12 @@ describe('POST cases', () => { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }, }); @@ -140,7 +175,12 @@ describe('POST cases', () => { closed_at: null, closed_by: null, comments: [], - connector_id: '123', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, created_at: '2019-11-25T21:54:48.952Z', created_by: { email: null, diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 50883667a5047..20d8bb7a19c1b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -15,7 +15,7 @@ import { CasePostRequestRt, throwErrors, excess, CaseResponseRt } from '../../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; -import { getConnectorId } from './helpers'; +import { getConnectorFromConfiguration, transformCaseConnectorToEsConnector } from './helpers'; export function initPostCaseApi({ caseService, @@ -42,7 +42,8 @@ export function initPostCaseApi({ const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const myCaseConfigure = await caseConfigureService.find({ client }); - const connectorId = getConnectorId(myCaseConfigure); + const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); + const newCase = await caseService.postNewCase({ client, attributes: transformNewCase({ @@ -51,7 +52,9 @@ export function initPostCaseApi({ username, full_name, email, - connectorId, + connector: transformCaseConnectorToEsConnector( + query.connector ?? caseConfigureConnector + ), }), }); 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 f7990b861f815..11e501bf8f71f 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 @@ -6,6 +6,7 @@ import { schema } from '@kbn/config-schema'; import Boom from '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'; @@ -16,7 +17,6 @@ import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASE_DETAILS_URL } from '../../../../common/constants'; -import { getConnectorId } from './helpers'; export function initPushCaseUserActionApi({ caseConfigureService, @@ -94,14 +94,13 @@ export function initPushCaseUserActionApi({ ...query, }; - const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + const updateConnector = myCase.attributes.connector; - // old case may not have new attribute connector_id, so we default to the configured system - const updateConnectorId = { - connector_id: myCase.attributes.connector_id ?? caseConfigureConnectorId, - }; - - if (!connectors.some((connector) => connector.id === updateConnectorId.connector_id)) { + 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'); } @@ -121,7 +120,6 @@ export function initPushCaseUserActionApi({ external_service: externalService, updated_at: pushedDate, updated_by: { username, full_name, email }, - ...updateConnectorId, }, version: myCase.version, }), diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 2da489e643435..bb38fae35ad50 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -23,13 +23,24 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; +import { ConnectorTypes, ESCaseConnector } from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { + const connector: ESCaseConnector = { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }; it('transform correctly', () => { const myCase = { newCase, - connectorId: '123', + connector, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', full_name: 'Elastic', @@ -42,7 +53,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, - connector_id: '123', + connector, created_at: '2020-04-09T09:43:51.778Z', created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, external_service: null, @@ -55,7 +66,7 @@ describe('Utils', () => { it('transform correctly without optional fields', () => { const myCase = { newCase, - connectorId: '123', + connector, createdDate: '2020-04-09T09:43:51.778Z', }; @@ -65,7 +76,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, - connector_id: '123', + connector, created_at: '2020-04-09T09:43:51.778Z', created_by: { email: undefined, full_name: undefined, username: undefined }, external_service: null, @@ -78,7 +89,7 @@ describe('Utils', () => { it('transform correctly with optional fields as null', () => { const myCase = { newCase, - connectorId: '123', + connector, createdDate: '2020-04-09T09:43:51.778Z', email: null, full_name: null, @@ -91,7 +102,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, - connector_id: '123', + connector, created_at: '2020-04-09T09:43:51.778Z', created_by: { email: null, full_name: null, username: null }, external_service: null, @@ -230,8 +241,7 @@ describe('Utils', () => { }, 2, 2, - extraCaseData, - '123' + extraCaseData ); expect(res).toEqual({ page: 1, @@ -239,8 +249,7 @@ describe('Utils', () => { total: mockCases.length, cases: flattenCaseSavedObjects( mockCases.map((obj) => ({ ...obj, score: 1 })), - extraCaseData, - '123' + extraCaseData ), count_open_cases: 2, count_closed_cases: 2, @@ -252,13 +261,19 @@ describe('Utils', () => { it('flattens correctly', () => { const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); + expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, - connector_id: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -285,53 +300,19 @@ describe('Utils', () => { it('it handles total comments correctly when caseId is not in extraCaseData', () => { const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, - connector_id: 'none', - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: 'open', - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - }, - ]); - }); - it('inserts missing connectorId', () => { - const extraCaseData = [ - { - caseId: mockCaseNoConnectorId.id, - totalComment: 0, - }, - ]; - - // @ts-ignore this is to update old case saved objects to include connector_id - const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData, '123'); - expect(res).toEqual([ - { - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector_id: '123', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -355,7 +336,8 @@ describe('Utils', () => { }, ]); }); - it('inserts missing connectorId (none)', () => { + + it('inserts missing connector', () => { const extraCaseData = [ { caseId: mockCaseNoConnectorId.id, @@ -363,14 +345,20 @@ describe('Utils', () => { }, ]; - // @ts-ignore this is to update old case saved objects to include connector_id + // @ts-ignore this is to update old case saved objects to include connector const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); + expect(res).toEqual([ { id: mockCaseNoConnectorId.id, closed_at: null, closed_by: null, - connector_id: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -398,90 +386,89 @@ describe('Utils', () => { describe('flattenCaseSavedObject', () => { it('flattens correctly', () => { - const myCase = { ...mockCases[0] }; - const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); + const myCase = { ...mockCases[2] }; + const res = flattenCaseSavedObject({ + savedObject: myCase, + totalComment: 2, + }); + expect(res).toEqual({ id: myCase.id, version: myCase.version, comments: [], totalComment: 2, ...myCase.attributes, + connector: { + ...myCase.attributes.connector, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, }); }); it('flattens correctly without version', () => { - const myCase = { ...mockCases[0] }; + const myCase = { ...mockCases[2] }; myCase.version = undefined; - const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); + const res = flattenCaseSavedObject({ + savedObject: myCase, + totalComment: 2, + }); + expect(res).toEqual({ id: myCase.id, version: '0', comments: [], totalComment: 2, ...myCase.attributes, + connector: { + ...myCase.attributes.connector, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, }); }); it('flattens correctly with comments', () => { - const myCase = { ...mockCases[0] }; + const myCase = { ...mockCases[2] }; const comments = [{ ...mockCaseComments[0] }]; - const res = flattenCaseSavedObject({ savedObject: myCase, comments, totalComment: 2 }); + const res = flattenCaseSavedObject({ + savedObject: myCase, + comments, + totalComment: 2, + }); + expect(res).toEqual({ id: myCase.id, version: myCase.version, comments: flattenCommentSavedObjects(comments), totalComment: 2, ...myCase.attributes, + connector: { + ...myCase.attributes.connector, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, }); }); - it('inserts missing connectorId', () => { + + it('inserts missing connector', () => { const extraCaseData = { totalComment: 2, - caseConfigureConnectorId: '123', }; - // @ts-ignore this is to update old case saved objects to include connector_id - const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); - expect(res).toEqual({ - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector_id: '123', - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: 'open', - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', + const res = flattenCaseSavedObject({ + // @ts-ignore this is to update old case saved objects to include connector + savedObject: mockCaseNoConnectorId, + ...extraCaseData, }); - }); - it('inserts missing connectorId (none)', () => { - const extraCaseData = { - totalComment: 2, - caseConfigureConnectorId: 'none', - }; - // @ts-ignore this is to update old case saved objects to include connector_id - const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); expect(res).toEqual({ id: mockCaseNoConnectorId.id, closed_at: null, closed_by: null, - connector_id: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 074957ec69bca..2202bda2be087 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -17,16 +17,18 @@ import { CasePostRequest, CaseResponse, CasesFindResponse, - CaseAttributes, CommentResponse, CommentsResponse, CommentAttributes, + ESCaseConnector, + ESCaseAttributes, } from '../../../common/api'; +import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ - connectorId, + connector, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -34,17 +36,17 @@ export const transformNewCase = ({ newCase, username, }: { - connectorId: string; + connector: ESCaseConnector; createdDate: string; email?: string | null; full_name?: string | null; newCase: CasePostRequest; username?: string | null; -}): CaseAttributes => ({ +}): ESCaseAttributes => ({ ...newCase, closed_at: null, closed_by: null, - connector_id: connectorId, + connector, created_at: createdDate, created_by: { email, full_name, username }, external_service: null, @@ -88,33 +90,30 @@ export function wrapError(error: any): CustomHttpResponseOptions } export const transformCases = ( - cases: SavedObjectsFindResponse, + cases: SavedObjectsFindResponse, countOpenCases: number, countClosedCases: number, - totalCommentByCase: TotalCommentByCase[], - caseConfigureConnectorId: string = 'none' + totalCommentByCase: TotalCommentByCase[] ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase, caseConfigureConnectorId), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( - savedObjects: Array>, - totalCommentByCase: TotalCommentByCase[], - caseConfigureConnectorId: string = 'none' + savedObjects: Array>, + totalCommentByCase: TotalCommentByCase[] ): CaseResponse[] => - savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { + savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { return [ ...acc, flattenCaseSavedObject({ savedObject, totalComment: totalCommentByCase.find((tc) => tc.caseId === savedObject.id)?.totalComments ?? 0, - caseConfigureConnectorId, }), ]; }, []); @@ -123,19 +122,17 @@ export const flattenCaseSavedObject = ({ savedObject, comments = [], totalComment = 0, - caseConfigureConnectorId = 'none', }: { - savedObject: SavedObject; + savedObject: SavedObject; comments?: Array>; totalComment?: number; - caseConfigureConnectorId?: string; }): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, ...savedObject.attributes, - connector_id: savedObject.attributes.connector_id ?? caseConfigureConnectorId, + connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), }); export const transformComments = ( diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 26ed6ab7cc0bc..d8ee2f90f3d93 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -5,6 +5,7 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { caseMigrations } from './migrations'; export const CASE_SAVED_OBJECT = 'cases'; @@ -49,8 +50,28 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, - connector_id: { - type: 'keyword', + connector: { + properties: { + id: { + type: 'keyword', + }, + name: { + type: 'text', + }, + type: { + type: 'keyword', + }, + fields: { + properties: { + key: { + type: 'text', + }, + value: { + type: 'text', + }, + }, + }, + }, }, external_service: { properties: { @@ -115,4 +136,5 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, + migrations: caseMigrations, }; diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index d6bc3c9f2e227..6c6badd31bbc3 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -5,6 +5,7 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { configureMigrations } from './migrations'; export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure'; @@ -30,11 +31,28 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, }, }, - connector_id: { - type: 'keyword', - }, - connector_name: { - type: 'keyword', + connector: { + properties: { + id: { + type: 'keyword', + }, + name: { + type: 'text', + }, + type: { + type: 'keyword', + }, + fields: { + properties: { + key: { + type: 'text', + }, + value: { + type: 'text', + }, + }, + }, + }, }, closure_type: { type: 'keyword', @@ -57,4 +75,5 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, }, }, + migrations: configureMigrations, }; diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts new file mode 100644 index 0000000000000..c3dd88799b5fb --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; +import { ConnectorTypes } from '../../common/api/connectors'; + +interface UnsanitizedCase { + connector_id: string; +} + +interface UnsanitizedConfigure { + connector_id: string; + connector_name: string; +} + +interface SanitizedCase { + connector: { + id: string; + name: string | null; + type: string | null; + fields: null; + }; +} + +interface SanitizedConfigure { + connector: { + id: string; + name: string | null; + type: string | null; + fields: null; + }; +} + +interface UserActions { + action_field: string[]; + new_value: string; + old_value: string; +} + +export const caseMigrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; + + return { + ...doc, + attributes: { + ...attributesWithoutConnectorId, + connector: { + id: connector_id ?? 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }, + references: doc.references || [], + }; + }, +}; + +export const configureMigrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { connector_id, connector_name, ...restAttributes } = doc.attributes; + + return { + ...doc, + attributes: { + ...restAttributes, + connector: { + id: connector_id ?? 'none', + name: connector_name ?? 'none', + type: ConnectorTypes.none, + fields: null, + }, + }, + references: doc.references || [], + }; + }, +}; + +export const userActionsMigrations = { + '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { + const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; + + if ( + action_field == null || + !Array.isArray(action_field) || + action_field[0] !== 'connector_id' + ) { + return { ...doc, references: doc.references || [] }; + } + + return { + ...doc, + attributes: { + ...restAttributes, + action_field: ['connector'], + new_value: + new_value != null + ? JSON.stringify({ + id: new_value, + name: 'none', + type: ConnectorTypes.none, + fields: null, + }) + : new_value, + old_value: + old_value != null + ? JSON.stringify({ + id: old_value, + name: 'none', + type: ConnectorTypes.none, + fields: null, + }) + : old_value, + }, + references: doc.references || [], + }; + }, +}; diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts index 826c6907efea6..ce3c1669e0368 100644 --- a/x-pack/plugins/case/server/saved_object_types/user_actions.ts +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -5,6 +5,7 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { userActionsMigrations } from './migrations'; export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; @@ -44,4 +45,5 @@ export const caseUserActionSavedObjectType: SavedObjectsType = { }, }, }, + migrations: userActionsMigrations, }; diff --git a/x-pack/plugins/case/server/services/configure/index.ts b/x-pack/plugins/case/server/services/configure/index.ts index 42c0dc293a648..8c6ba6d90c200 100644 --- a/x-pack/plugins/case/server/services/configure/index.ts +++ b/x-pack/plugins/case/server/services/configure/index.ts @@ -12,7 +12,7 @@ import { SavedObjectsUpdateResponse, } from 'kibana/server'; -import { CasesConfigureAttributes, SavedObjectFindOptions } from '../../../common/api'; +import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common/api'; import { CASE_CONFIGURE_SAVED_OBJECT } from '../../saved_object_types'; interface ClientArgs { @@ -27,22 +27,22 @@ interface FindCaseConfigureArgs extends ClientArgs { } interface PostCaseConfigureArgs extends ClientArgs { - attributes: CasesConfigureAttributes; + attributes: ESCasesConfigureAttributes; } interface PatchCaseConfigureArgs extends ClientArgs { caseConfigureId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; } export interface CaseConfigureServiceSetup { delete(args: GetCaseConfigureArgs): Promise<{}>; - get(args: GetCaseConfigureArgs): Promise>; - find(args: FindCaseConfigureArgs): Promise>; + get(args: GetCaseConfigureArgs): Promise>; + find(args: FindCaseConfigureArgs): Promise>; patch( args: PatchCaseConfigureArgs - ): Promise>; - post(args: PostCaseConfigureArgs): Promise>; + ): Promise>; + post(args: PostCaseConfigureArgs): Promise>; } export class CaseConfigureService { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 2ca3e4e9ec223..3db83331a0ab9 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -18,7 +18,12 @@ import { } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; -import { CaseAttributes, CommentAttributes, SavedObjectFindOptions, User } from '../../common/api'; +import { + ESCaseAttributes, + CommentAttributes, + SavedObjectFindOptions, + User, +} from '../../common/api'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -55,7 +60,7 @@ interface GetCommentArgs extends ClientArgs { } interface PostCaseArgs extends ClientArgs { - attributes: CaseAttributes; + attributes: ESCaseAttributes; } interface PostCommentArgs extends ClientArgs { @@ -65,7 +70,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -100,18 +105,18 @@ interface CaseServiceDeps { export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; - findCases(args: FindCasesArgs): Promise>; + findCases(args: FindCasesArgs): Promise>; getAllCaseComments(args: FindCommentsArgs): Promise>; - getCase(args: GetCaseArgs): Promise>; - getCases(args: GetCasesArgs): Promise>; + getCase(args: GetCaseArgs): Promise>; + getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; getReporters(args: ClientArgs): Promise; getUser(args: GetUserArgs): Promise; - postNewCase(args: PostCaseArgs): Promise>; + postNewCase(args: PostCaseArgs): Promise>; postNewComment(args: PostCommentArgs): Promise>; - patchCase(args: PatchCaseArgs): Promise>; - patchCases(args: PatchCasesArgs): Promise>; + patchCase(args: PatchCaseArgs): Promise>; + patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; patchComments(args: PatchComments): Promise>; } diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 5b7d1f4618fed..c9339862b8f24 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -5,16 +5,20 @@ */ import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server'; -import { get } from 'lodash'; +import { get, isPlainObject, isString } from 'lodash'; +import deepEqual from 'fast-deep-equal'; import { CaseUserActionAttributes, UserAction, UserActionField, - CaseAttributes, + ESCaseAttributes, User, } from '../../../common/api'; -import { isTwoArraysDifference } from '../../routes/api/cases/helpers'; +import { + isTwoArraysDifference, + transformESConnectorToCaseConnector, +} from '../../routes/api/cases/helpers'; import { UserActionItem } from '.'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; @@ -120,7 +124,7 @@ export const buildCaseUserActionItem = ({ const userActionFieldsAllowed: UserActionField = [ 'comment', - 'connector_id', + 'connector', 'description', 'tags', 'title', @@ -135,8 +139,8 @@ export const buildCaseUserActions = ({ }: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; + originalCases: Array>; + updatedCases: Array>; }): UserActionItem[] => updatedCases.reduce((acc, updatedItem) => { const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id); @@ -145,11 +149,32 @@ export const buildCaseUserActions = ({ const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { if (userActionFieldsAllowed.includes(field)) { - const origValue = get(originalItem, ['attributes', field]); - const updatedValue = get(updatedItem, ['attributes', field]); - const compareValues = isTwoArraysDifference(origValue, updatedValue); - if (compareValues != null) { - if (compareValues.addedItems.length > 0) { + const origValue = + field === 'connector' && originalItem.attributes.connector + ? transformESConnectorToCaseConnector(originalItem.attributes.connector) + : get(originalItem, ['attributes', field]); + + const updatedValue = + field === 'connector' && updatedItem.attributes.connector + ? transformESConnectorToCaseConnector(updatedItem.attributes.connector) + : get(updatedItem, ['attributes', field]); + + if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'update', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: updatedValue, + oldValue: origValue, + }), + ]; + } else if (Array.isArray(origValue) && Array.isArray(updatedValue)) { + const compareValues = isTwoArraysDifference(origValue, updatedValue); + if (compareValues && compareValues.addedItems.length > 0) { userActions = [ ...userActions, buildCaseUserActionItem({ @@ -162,7 +187,8 @@ export const buildCaseUserActions = ({ }), ]; } - if (compareValues.deletedItems.length > 0) { + + if (compareValues && compareValues.deletedItems.length > 0) { userActions = [ ...userActions, buildCaseUserActionItem({ @@ -175,7 +201,11 @@ export const buildCaseUserActions = ({ }), ]; } - } else if (origValue !== updatedValue) { + } else if ( + isPlainObject(origValue) && + isPlainObject(updatedValue) && + !deepEqual(origValue, updatedValue) + ) { userActions = [ ...userActions, buildCaseUserActionItem({ @@ -184,8 +214,8 @@ export const buildCaseUserActions = ({ actionBy, caseId: updatedItem.id, fields: [field], - newValue: updatedValue, - oldValue: origValue, + newValue: JSON.stringify(updatedValue), + oldValue: JSON.stringify(origValue), }), ]; } 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 3859b4527991b..58d7efb763ee2 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 @@ -15,12 +15,13 @@ import { TestProviders } from '../../../common/mock'; import { useUpdateCase } from '../../containers/use_update_case'; import { useGetCase } from '../../containers/use_get_case'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; -import { waitFor } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; jest.mock('../../containers/use_update_case'); jest.mock('../../containers/use_get_case_user_actions'); @@ -37,7 +38,15 @@ const usePostPushToServiceMock = usePostPushToService as jest.Mock; export const caseProps: CaseProps = { caseId: basicCase.id, userCanCrud: true, - caseData: { ...basicCase, connectorId: 'servicenow-2' }, + caseData: { + ...basicCase, + connector: { + id: 'resilient-2', + name: 'Resilient', + type: ConnectorTypes.resilient, + fields: null, + }, + }, fetchCase: jest.fn(), updateCase: jest.fn(), }; @@ -275,7 +284,8 @@ describe('CaseView ', () => { .first() .exists() ).toBeTruthy(); - expect(wrapper.find('[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy(); + + expect(wrapper.find('button[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy(); }); }); @@ -442,34 +452,108 @@ describe('CaseView ', () => { ).toBeTruthy(); }); }); - - it('should revert to the initial connector in case of failure', async () => { + // TO DO fix when the useEffects in edit_connector are cleaned up + it.skip('should revert to the initial connector in case of failure', async () => { updateCaseProperty.mockImplementation(({ onError }) => { onError(); }); + const wrapper = mount( ); + const connectorName = wrapper + .find('[data-test-subj="settings-connector-card"] .euiTitle') + .first() + .text(); + await waitFor(() => { + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); + }); + + await waitFor(() => { + wrapper.update(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); wrapper.update(); wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); - wrapper.update(); }); + await waitFor(() => { wrapper.update(); + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('connector'); expect( - wrapper.find('[data-test-subj="dropdown-connectors"]').at(0).prop('valueOfSelected') - ).toBe('servicenow-1'); + wrapper.find('[data-test-subj="settings-connector-card"] .euiTitle').first().text() + ).toBe(connectorName); + }); + }); + // TO DO fix when the useEffects in edit_connector are cleaned up + it.skip('should update connector', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); + }); + + await waitFor(() => { + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + wrapper.update(); + }); + + act(() => { + wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); + }); + + await waitFor(() => { + wrapper.update(); + }); + + const updateObject = updateCaseProperty.mock.calls[0][0]; + expect(updateObject.updateKey).toEqual('connector'); + expect(updateObject.updateValue).toEqual({ + id: 'resilient-2', + name: 'My Connector 2', + type: ConnectorTypes.resilient, + fields: { + incidentTypes: null, + severityCode: 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 750ff49cd700c..52cea10cfb275 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 @@ -14,10 +14,11 @@ import { } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; +import { isEmpty } from 'lodash/fp'; import * as i18n from './translations'; -import { Case } from '../../containers/types'; -import { getCaseUrl } from '../../../common/components/link_to'; +import { Case, CaseConnector } from '../../containers/types'; +import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; import { HeaderPage } from '../../../common/components/header_page'; import { EditableTitle } from '../../../common/components/header_page/editable_title'; @@ -26,18 +27,20 @@ import { useGetCase } from '../../containers/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../containers/use_update_case'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; import { getTypedPayload } from '../../containers/utils'; import { WhitePageWrapper, HeaderWrapper } from '../wrappers'; -import { useBasePath } from '../../../common/lib/kibana'; import { CaseStatus } from '../case_status'; -import { navTabs } from '../../../app/home/home_navigations'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; import { usePushToService } from '../use_push_to_service'; import { EditConnector } from '../edit_connector'; import { useConnectors } from '../../containers/configure/use_connectors'; import { SecurityPageName } from '../../../app/types'; +import { + getConnectorById, + normalizeActionConnector, + getNoneConnector, +} from '../configure_cases/utils'; interface Props { caseId: string; @@ -77,10 +80,11 @@ export interface CaseProps extends Props { export const CaseComponent = React.memo( ({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => { - const basePath = window.location.origin + useBasePath(); - const caseLink = `${basePath}/app/security/cases/${caseId}`; - const search = useGetUrlSearch(navTabs.case); + const { formatUrl, search } = useFormatUrl(SecurityPageName.case); + const allCasesLink = getCaseUrl(search); + const caseDetailsLink = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true }); const [initLoadingData, setInitLoadingData] = useState(true); + const { caseUserActions, fetchCaseUserActions, @@ -88,7 +92,8 @@ export const CaseComponent = React.memo( hasDataToPush, isLoading: isLoadingUserActions, participants, - } = useGetCaseUserActions(caseId, caseData.connectorId); + } = useGetCaseUserActions(caseId, caseData.connector.id); + const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({ caseId, }); @@ -113,13 +118,13 @@ export const CaseComponent = React.memo( }); } break; - case 'connectorId': - const connectorId = getTypedPayload(value); - if (connectorId.length > 0) { + case 'connector': + const connector = getTypedPayload(value); + if (connector != null) { updateCaseProperty({ fetchCaseUserActions, - updateKey: 'connector_id', - updateValue: connectorId, + updateKey: 'connector', + updateValue: connector, updateCase: handleUpdateNewCase, version: caseData.version, onSuccess, @@ -172,6 +177,7 @@ export const CaseComponent = React.memo( }, [fetchCaseUserActions, updateCaseProperty, updateCase, caseData] ); + const handleUpdateCase = useCallback( (newCase: Case) => { updateCase(newCase); @@ -182,22 +188,24 @@ export const CaseComponent = React.memo( const { loading: isLoadingConnectors, connectors } = useConnectors(); - const [caseConnectorName, isValidConnector] = useMemo(() => { - const connector = connectors.find((c) => c.id === caseData.connectorId); - return [connector?.name ?? 'none', !!connector]; - }, [connectors, caseData.connectorId]); + const [connectorName, isValidConnector] = useMemo(() => { + const connector = connectors.find((c) => c.id === caseData.connector.id); + return [connector?.name ?? '', !!connector]; + }, [connectors, caseData.connector]); const currentExternalIncident = useMemo( () => - caseServices != null && caseServices[caseData.connectorId] != null - ? caseServices[caseData.connectorId] + caseServices != null && caseServices[caseData.connector.id] != null + ? caseServices[caseData.connector.id] : null, - [caseServices, caseData.connectorId] + [caseServices, caseData.connector] ); const { pushButton, pushCallouts } = usePushToService({ - caseConnectorId: caseData.connectorId, - caseConnectorName, + connector: { + ...caseData.connector, + name: isEmpty(caseData.connector.name) ? connectorName : caseData.connector.name, + }, caseServices, caseId: caseData.id, caseStatus: caseData.status, @@ -208,22 +216,31 @@ export const CaseComponent = React.memo( }); const onSubmitConnector = useCallback( - (connectorId, onSuccess, onError) => + (connectorId, connectorFields, onError, onSuccess) => { + const connector = getConnectorById(connectorId, connectors); + const connectorToUpdate = connector + ? normalizeActionConnector(connector) + : getNoneConnector(); + onUpdateField({ - key: 'connectorId', - value: connectorId, + key: 'connector', + value: { ...connectorToUpdate, fields: connectorFields }, onSuccess, onError, - }), - [onUpdateField] + }); + }, + [onUpdateField, connectors] ); + const onSubmitTags = useCallback((newTags) => onUpdateField({ key: 'tags', value: newTags }), [ onUpdateField, ]); + const onSubmitTitle = useCallback( (newTitle) => onUpdateField({ key: 'title', value: newTitle }), [onUpdateField] ); + const toggleStatusCase = useCallback( (e) => onUpdateField({ @@ -232,6 +249,7 @@ export const CaseComponent = React.memo( }), [onUpdateField] ); + const handleRefresh = useCallback(() => { fetchCaseUserActions(caseData.id); fetchCase(); @@ -264,12 +282,13 @@ export const CaseComponent = React.memo( }, [caseData.closedAt, caseData.createdAt, caseData.status] ); + const emailContent = useMemo( () => ({ subject: i18n.EMAIL_SUBJECT(caseData.title), - body: i18n.EMAIL_BODY(caseLink), + body: i18n.EMAIL_BODY(caseDetailsLink), }), - [caseLink, caseData.title] + [caseDetailsLink, caseData.title] ); useEffect(() => { @@ -280,12 +299,12 @@ export const CaseComponent = React.memo( const backOptions = useMemo( () => ({ - href: getCaseUrl(search), + href: allCasesLink, text: i18n.BACK_TO_ALL, dataTestSubj: 'backToCases', pageId: SecurityPageName.case, }), - [search] + [allCasesLink] ); return ( @@ -380,10 +399,13 @@ export const CaseComponent = React.memo( isLoading={isLoading && updateKey === 'tags'} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts index b8219ad52f5b0..04bb8801c9f00 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/translations.ts @@ -151,6 +151,14 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); + export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', { defaultMessage: 'Unknown', }); + +export const CHANGED_CONNECTOR_FIELD = i18n.translate( + 'xpack.securitySolution.case.caseView.fieldChanged', + { + defaultMessage: `changed connector field`, + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index 08303ddc9397e..f3b0b2b840ab4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Connector } from '../../../containers/configure/types'; -import { ReturnConnectors } from '../../../containers/configure/use_connectors'; +import { ActionConnector } from '../../../containers/configure/types'; +import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; import { connectorsMock } from '../../../containers/configure/mock'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; export { mapping } from '../../../containers/configure/mock'; +import { ConnectorTypes } from '../../../../../../case/common/api'; -export const connectors: Connector[] = connectorsMock; +export const connectors: ActionConnector[] = connectorsMock; // x - pack / plugins / triggers_actions_ui; export const searchURL = @@ -18,12 +19,20 @@ export const searchURL = export const useCaseConfigureResponse: ReturnUseCaseConfigure = { closureType: 'close-by-user', - connectorId: 'none', - connectorName: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, currentConfiguration: { - connectorId: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, closureType: 'close-by-user', - connectorName: 'none', }, firstLoad: false, loading: false, @@ -38,7 +47,7 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = { version: '', }; -export const useConnectorsResponse: ReturnConnectors = { +export const useConnectorsResponse: UseConnectorsResponse = { loading: false, connectors, refetchConnectors: jest.fn(), 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 ac7863d574dc9..5272d13043fc4 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 @@ -58,10 +58,10 @@ describe('Connectors', () => { test('the connector is changed successfully', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); expect(onChangeConnector).toHaveBeenCalled(); - expect(onChangeConnector).toHaveBeenCalledWith('servicenow-2'); + expect(onChangeConnector).toHaveBeenCalledWith('resilient-2'); }); test('the connector is changed successfully to none', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx index b8151cb6fe18c..fd7510a1c4713 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.tsx @@ -18,7 +18,7 @@ import styled from 'styled-components'; import { ConnectorsDropdown } from './connectors_dropdown'; import * as i18n from './translations'; -import { Connector } from '../../containers/configure/types'; +import { ActionConnector } from '../../containers/configure/types'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -29,7 +29,7 @@ const EuiFormRowExtended = styled(EuiFormRow)` `; export interface Props { - connectors: Connector[]; + connectors: ActionConnector[]; disabled: boolean; isLoading: boolean; updateConnectorDisabled: boolean; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx index f817cb0ffb617..a1d55ded22d77 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx @@ -44,8 +44,8 @@ describe('ConnectorsDropdown', () => { 'data-test-subj': 'dropdown-connector-servicenow-1', }), expect.objectContaining({ - value: 'servicenow-2', - 'data-test-subj': 'dropdown-connector-servicenow-2', + value: 'resilient-2', + 'data-test-subj': 'dropdown-connector-resilient-2', }), ]) ); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx index 018d3a0fdb4e0..895406b4f77c3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx @@ -8,12 +8,12 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; -import { Connector } from '../../containers/configure/types'; +import { ActionConnector } from '../../containers/configure/types'; import { connectorsConfiguration } from '../../../common/lib/connectors/config'; import * as i18n from './translations'; export interface Props { - connectors: Connector[]; + connectors: ActionConnector[]; disabled: boolean; isLoading: boolean; onChange: (id: string) => void; @@ -96,13 +96,14 @@ const ConnectorsDropdownComponent: React.FC = ({ return ( ); }; 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 a012b4171fa23..fba352d3ee582 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 @@ -25,6 +25,7 @@ import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; import { connectors, searchURL, useCaseConfigureResponse, useConnectorsResponse } from './__mock__'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; jest.mock('../../../common/lib/kibana'); jest.mock('../../containers/configure/use_connectors'); @@ -90,11 +91,19 @@ describe('ConfigureCases', () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, closureType: 'close-by-user', - connectorId: 'not-id', - connectorName: 'unchanged', + connector: { + id: 'not-id', + name: 'unchanged', + type: ConnectorTypes.none, + fields: null, + }, currentConfiguration: { - connectorName: 'unchanged', - connectorId: 'not-id', + connector: { + id: 'not-id', + name: 'unchanged', + type: ConnectorTypes.none, + fields: null, + }, closureType: 'close-by-user', }, })); @@ -126,11 +135,19 @@ describe('ConfigureCases', () => { ...useCaseConfigureResponse, mapping: connectors[0].config.incidentConfiguration.mapping, closureType: 'close-by-user', - connectorId: 'servicenow-1', - connectorName: 'unchanged', + connector: { + id: 'servicenow-1', + name: 'unchanged', + type: ConnectorTypes.servicenow, + fields: null, + }, currentConfiguration: { - connectorName: 'unchanged', - connectorId: 'servicenow-1', + connector: { + id: 'servicenow-1', + name: 'unchanged', + type: ConnectorTypes.servicenow, + fields: null, + }, closureType: 'close-by-user', }, })); @@ -213,11 +230,19 @@ describe('ConfigureCases', () => { ...useCaseConfigureResponse, mapping: connectors[1].config.incidentConfiguration.mapping, closureType: 'close-by-user', - connectorId: 'servicenow-2', - connectorName: 'unchanged', + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.resilient, + fields: null, + }, currentConfiguration: { - connectorName: 'unchanged', - connectorId: 'servicenow-1', + connector: { + id: 'servicenow-1', + name: 'unchanged', + type: ConnectorTypes.servicenow, + fields: null, + }, closureType: 'close-by-user', }, })); @@ -258,7 +283,12 @@ describe('ConfigureCases', () => { beforeEach(() => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connectorId: 'servicenow-1', + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.servicenow, + fields: null, + }, persistLoading: true, })); @@ -327,11 +357,19 @@ describe('ConfigureCases', () => { ...useCaseConfigureResponse, mapping: connectors[0].config.incidentConfiguration.mapping, closureType: 'close-by-user', - connectorId: 'servicenow-1', - connectorName: 'My connector', + connector: { + id: 'resilient-2', + name: 'My connector', + type: ConnectorTypes.resilient, + fields: null, + }, currentConfiguration: { - connectorName: 'My connector', - connectorId: 'My connector', + connector: { + id: 'My connector', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, closureType: 'close-by-user', }, persistCaseConfigure, @@ -345,13 +383,17 @@ describe('ConfigureCases', () => { test('it submits the configuration correctly when changing connector', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); wrapper.update(); expect(persistCaseConfigure).toHaveBeenCalled(); expect(persistCaseConfigure).toHaveBeenCalledWith({ - connectorId: 'servicenow-2', - connectorName: 'My Connector 2', + connector: { + id: 'resilient-2', + name: 'My Connector 2', + type: ConnectorTypes.resilient, + fields: null, + }, closureType: 'close-by-user', }); }); @@ -360,18 +402,28 @@ describe('ConfigureCases', () => { useCaseConfigureMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - connectorId: 'servicenow-1', + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.servicenow, + fields: null, + }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - connectorId: 'servicenow-2', + connector: { + id: 'resilient-2', + name: 'My connector 2', + type: ConnectorTypes.resilient, + fields: null, + }, })); wrapper = mount(, { wrappingComponent: TestProviders }); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); wrapper.update(); expect( @@ -393,11 +445,19 @@ describe('closure options', () => { ...useCaseConfigureResponse, mapping: connectors[0].config.incidentConfiguration.mapping, closureType: 'close-by-user', - connectorId: 'servicenow-1', - connectorName: 'My connector', + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.servicenow, + fields: null, + }, currentConfiguration: { - connectorName: 'My connector', - connectorId: 'My connector', + connector: { + id: 'My connector', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, closureType: 'close-by-user', }, persistCaseConfigure, @@ -414,8 +474,12 @@ describe('closure options', () => { expect(persistCaseConfigure).toHaveBeenCalled(); expect(persistCaseConfigure).toHaveBeenCalledWith({ - connectorId: 'servicenow-1', - connectorName: 'My Connector', + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.servicenow, + fields: null, + }, closureType: 'close-by-pushing', }); }); @@ -427,11 +491,19 @@ describe('user interactions', () => { ...useCaseConfigureResponse, mapping: connectors[1].config.incidentConfiguration.mapping, closureType: 'close-by-user', - connectorId: 'servicenow-2', - connectorName: 'unchanged', + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.resilient, + fields: null, + }, currentConfiguration: { - connectorName: 'unchanged', - connectorId: 'servicenow-2', + connector: { + id: 'resilient-2', + name: 'unchanged', + type: ConnectorTypes.servicenow, + fields: null, + }, closureType: 'close-by-user', }, })); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index 7b57f9ac60990..9418eabaec352 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -25,9 +25,15 @@ import { ClosureType } from '../../containers/configure/types'; import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types'; import { connectorsConfiguration } from '../../../common/lib/connectors/config'; +import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; -import { SectionWrapper } from '../wrappers'; +import { + getConnectorById, + getNoneConnector, + normalizeActionConnector, + normalizeCaseConnector, +} from './utils'; import * as i18n from './translations'; const FormWrapper = styled.div` @@ -65,12 +71,10 @@ const ConfigureCasesComponent: React.FC = ({ userC ); const { - connectorId, + connector, closureType, - currentConfiguration, loading: loadingCaseConfigure, persistLoading, - version, persistCaseConfigure, setConnector, setClosureType, @@ -83,7 +87,7 @@ const ConfigureCasesComponent: React.FC = ({ userC // eslint-disable-next-line react-hooks/exhaustive-deps const reloadConnectors = useCallback(async () => refetchConnectors(), []); const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure; - const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connectorId === 'none'; + const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); @@ -93,16 +97,14 @@ const ConfigureCasesComponent: React.FC = ({ userC (isVisible: boolean) => { setAddFlyoutVisibility(isVisible); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [currentConfiguration, connectorId, closureType] + [setAddFlyoutVisibility] ); const handleSetEditFlyoutVisibility = useCallback( (isVisible: boolean) => { setEditFlyoutVisibility(isVisible); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [currentConfiguration, connectorId, closureType] + [setEditFlyoutVisibility] ); const onChangeConnector = useCallback( @@ -112,54 +114,52 @@ const ConfigureCasesComponent: React.FC = ({ userC return; } - setConnector(id); + const actionConnector = getConnectorById(id, connectors); + const caseConnector = + actionConnector != null ? normalizeActionConnector(actionConnector) : getNoneConnector(); + + setConnector(caseConnector); persistCaseConfigure({ - connectorId: id, - connectorName: connectors.find((c) => c.id === id)?.name ?? '', + connector: caseConnector, closureType, }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [connectorId, closureType, version] + [connectors, closureType, persistCaseConfigure, setConnector] ); const onChangeClosureType = useCallback( (type: ClosureType) => { setClosureType(type); persistCaseConfigure({ - connectorId, - connectorName: connectors.find((c) => c.id === connectorId)?.name ?? '', + connector, closureType: type, }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [connectorId, closureType, version] + [connector, persistCaseConfigure, setClosureType] ); useEffect(() => { if ( !isLoadingConnectors && - connectorId !== 'none' && - !connectors.some((c) => c.id === connectorId) + connector.id !== 'none' && + !connectors.some((c) => c.id === connector.id) ) { setConnectorIsValid(false); } else if ( !isLoadingConnectors && - (connectorId === 'none' || connectors.some((c) => c.id === connectorId)) + (connector.id === 'none' || connectors.some((c) => c.id === connector.id)) ) { setConnectorIsValid(true); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connectors, connectorId]); + }, [connectors, connector, isLoadingConnectors]); useEffect(() => { - if (!isLoadingConnectors && connectorId !== 'none') { + if (!isLoadingConnectors && connector.id !== 'none') { setEditedConnectorItem( - connectors.find((c) => c.id === connectorId) as ActionConnectorTableItem + normalizeCaseConnector(connectors, connector) as ActionConnectorTableItem ); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connectors, connectorId]); + }, [connectors, connector, isLoadingConnectors]); return ( @@ -190,7 +190,7 @@ const ConfigureCasesComponent: React.FC = ({ userC onChangeConnector={onChangeConnector} updateConnectorDisabled={updateConnectorDisabled || !userCanCrud} handleShowEditFlyout={onClickUpdateConnector} - selectedConnector={connectorId} + selectedConnector={connector.id} /> ({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, +}); + +export const getConnectorById = ( + id: string, + connectors: ActionConnector[] +): ActionConnector | null => connectors.find((c) => c.id === id) ?? null; + +export const normalizeActionConnector = ( + actionConnector: ActionConnector, + fields: CaseConnector['fields'] = null +): CaseConnector => { + const caseConnectorFieldsType = { + type: actionConnector.actionTypeId, + fields, + } as ConnectorTypeFields; + return { + id: actionConnector.id, + name: actionConnector.name, + ...caseConnectorFieldsType, + }; +}; + +export const normalizeCaseConnector = ( + connectors: ActionConnector[], + caseConnector: CaseConnector +): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null; 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 1706fa38bb8a0..7de7b3d6b2a96 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 @@ -9,24 +9,26 @@ import React, { useCallback, useEffect } from 'react'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; -import { Connector } from '../../../../../case/common/api/cases'; +import { ActionConnector } from '../../../../../case/common/api/cases'; interface ConnectorSelectorProps { - connectors: Connector[]; + connectors: ActionConnector[]; dataTestSubj: string; + defaultValue?: ActionConnector; + disabled: boolean; field: FieldHook; idAria: string; - defaultValue?: string; - disabled: boolean; + isEdit: boolean; isLoading: boolean; } export const ConnectorSelector = ({ connectors, dataTestSubj, defaultValue, + disabled = false, field, idAria, - disabled = false, + isEdit = true, isLoading = false, }: ConnectorSelectorProps) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -37,13 +39,13 @@ export const ConnectorSelector = ({ }, [defaultValue]); const handleContentChange = useCallback( - (newContent: string) => { - field.setValue(newContent); + (newConnector: string) => { + field.setValue(newConnector); }, [field] ); - return ( + return isEdit ? ( - ); + ) : null; }; 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 d27f00aacff2c..3b63bddb204ea 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 @@ -19,6 +19,9 @@ import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/fo import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'; import { waitFor } from '@testing-library/react'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { connectorsMock } from '../../containers/configure/mock'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -40,6 +43,7 @@ jest.mock( ); jest.mock('../../containers/use_get_tags'); +jest.mock('../../containers/configure/use_connectors'); jest.mock( '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider', () => ({ @@ -47,7 +51,7 @@ jest.mock( children({ tags: ['rad', 'dude'] }), }) ); - +const useConnectorsMock = useConnectors as jest.Mock; const useFormMock = useForm as jest.Mock; const useFormDataMock = useFormData as jest.Mock; @@ -72,6 +76,12 @@ const sampleData = { description: 'what a great description', tags: sampleTags, title: 'what a cool title', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, }; const defaultPostCase = { isLoading: false, @@ -79,6 +89,7 @@ const defaultPostCase = { caseData: null, postCase, }; +const sampleConnectorData = { loading: false, connectors: [] }; describe('Create case', () => { const fetchTags = jest.fn(); const formHookMock = getFormMock(sampleData); @@ -87,7 +98,12 @@ describe('Create case', () => { useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline); usePostCaseMock.mockImplementation(() => defaultPostCase); useFormMock.mockImplementation(() => ({ form: formHookMock })); - useFormDataMock.mockImplementation(() => [{ description: sampleData.description }]); + useFormDataMock.mockImplementation(() => [ + { + description: sampleData.description, + }, + ]); + useConnectorsMock.mockReturnValue(sampleConnectorData); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); (useGetTags as jest.Mock).mockImplementation(() => ({ tags: sampleTags, @@ -95,63 +111,122 @@ describe('Create case', () => { })); }); - it('should post case on submit click', async () => { - const wrapper = mount( - - - - - - ); - wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); - }); - - it('should redirect to all cases on cancel click', () => { - const wrapper = mount( - - - - - - ); - wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); - expect(mockHistory.push).toHaveBeenCalledWith('/'); - }); - it('should redirect to new case when caseData is there', () => { - const sampleId = '777777'; - usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, caseData: { id: sampleId } })); - mount( - - - - - - ); - expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/777777'); - }); - - it('should render spinner when loading', () => { - usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, isLoading: true })); - const wrapper = mount( - - - - - - ); - expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy(); + describe('Step 1 - Case Fields', () => { + it('should post case on submit click', async () => { + const wrapper = mount( + + + + + + ); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); + }); + + it('should redirect to all cases on cancel click', async () => { + const wrapper = mount( + + + + + + ); + wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click'); + await waitFor(() => expect(mockHistory.push).toHaveBeenCalledWith('/')); + }); + it('should redirect to new case when caseData is there', async () => { + const sampleId = '777777'; + usePostCaseMock.mockImplementation(() => ({ + ...defaultPostCase, + caseData: { id: sampleId }, + })); + mount( + + + + + + ); + await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/777777')); + }); + + it('should render spinner when loading', async () => { + usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, isLoading: true })); + const wrapper = mount( + + + + + + ); + await waitFor(() => + expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy() + ); + }); + it('Tag options render with new tags added', async () => { + const wrapper = mount( + + + + + + ); + await waitFor(() => + expect( + wrapper + .find(`[data-test-subj="caseTags"] [data-test-subj="input"]`) + .first() + .prop('options') + ).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]) + ); + }); }); - it('Tag options render with new tags added', () => { - const wrapper = mount( - - - - - - ); - expect( - wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options') - ).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]); + describe('Step 2 - Connector Fields', () => { + const connectorTypes = [ + { + label: 'Jira', + testId: 'jira-1', + dataTestSubj: 'connector-settings-jira', + }, + { + label: 'Resilient', + testId: 'resilient-2', + dataTestSubj: 'connector-settings-resilient', + }, + { + label: 'ServiceNow', + testId: 'servicenow-1', + dataTestSubj: 'connector-settings-sn', + }, + ]; + connectorTypes.forEach(({ label, testId, dataTestSubj }) => { + it(`should change from none to ${label} connector fields`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find(`[data-test-subj="${dataTestSubj}"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-${testId}"]`).simulate('click'); + wrapper.update(); + }); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="${dataTestSubj}"]`).exists()).toBeTruthy(); + }); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index a8babe729fde0..b7a80bcf6633c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiButtonEmpty, @@ -11,44 +11,55 @@ import { EuiFlexItem, EuiLoadingSpinner, EuiPanel, + EuiSteps, } from '@elastic/eui'; import styled, { css } from 'styled-components'; import { useHistory } from 'react-router-dom'; - import { isEqual } from 'lodash/fp'; -import { CasePostRequest } from '../../../../../case/common/api'; + import { Field, Form, + FormDataProvider, getUseField, - useForm, UseField, - FormDataProvider, + useForm, useFormData, } from '../../../shared_imports'; import { usePostCase } from '../../containers/use_post_case'; -import { schema } from './schema'; +import { schema, FormProps } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; -import * as i18n from '../../translations'; import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { useGetTags } from '../../containers/use_get_tags'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; +import { SettingFieldsForm } from '../settings/fields_form'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { ConnectorSelector } from '../connector_selector/form'; +import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { + normalizeCaseConnector, + getConnectorById, + getNoneConnector, + normalizeActionConnector, +} from '../configure_cases/utils'; +import { ActionConnector } from '../../containers/types'; +import { ConnectorFields } from '../../../../../case/common/api/connectors'; +import * as i18n from './translations'; export const CommonUseField = getUseField({ component: Field }); -const ContainerBig = styled.div` - ${({ theme }) => css` - margin-top: ${theme.eui.euiSizeXL}; - `} -`; +interface ContainerProps { + big?: boolean; +} -const Container = styled.div` - ${({ theme }) => css` - margin-top: ${theme.eui.euiSize}; +const Container = styled.div.attrs((props) => props)` + ${({ big, theme }) => css` + margin-top: ${big ? theme.eui.euiSizeXL : theme.eui.euiSize}; `} `; + const MySpinner = styled(EuiLoadingSpinner)` position: absolute; top: 50%; @@ -56,32 +67,29 @@ const MySpinner = styled(EuiLoadingSpinner)` z-index: 99; `; -const initialCaseValue: CasePostRequest = { +const initialCaseValue: FormProps = { description: '', tags: [], title: '', + connectorId: 'none', }; export const Create = React.memo(() => { const history = useHistory(); const { caseData, isLoading, postCase } = usePostCase(); - const { form } = useForm({ - defaultValue: initialCaseValue, - options: { stripEmptyFields: false }, - schema, - }); - - const fieldName = 'description'; - const { submit, setFieldValue } = form; - const [{ description }] = useFormData({ form, watch: [fieldName] }); - + const { loading: isLoadingConnectors, connectors } = useConnectors(); + const { connector: configureConnector, loading: isLoadingCaseConfigure } = useCaseConfigure(); const { tags: tagOptions } = useGetTags(); + + const [connector, setConnector] = useState(null); const [options, setOptions] = useState( tagOptions.map((label) => ({ label, })) ); + // This values uses useEffect to update, not useMemo, + // because we need to setState on it from the jsx useEffect( () => setOptions( @@ -92,7 +100,39 @@ export const Create = React.memo(() => { [tagOptions] ); - const onDescriptionChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [ + const [fields, setFields] = useState(null); + + const { form } = useForm({ + defaultValue: initialCaseValue, + options: { stripEmptyFields: false }, + schema, + }); + const currentConnectorId = useMemo( + () => + !isLoadingCaseConfigure + ? normalizeCaseConnector(connectors, configureConnector)?.id ?? 'none' + : null, + [configureConnector, connectors, isLoadingCaseConfigure] + ); + const { submit, setFieldValue } = form; + const [{ description }] = useFormData<{ + description: string; + }>({ + form, + watch: ['description'], + }); + const onChangeConnector = useCallback( + (newConnectorId) => { + if (connector == null || connector.id !== newConnectorId) { + setConnector(getConnectorById(newConnectorId, connectors) ?? null); + // Reset setting fields when changing connector + setFields(null); + } + }, + [connector, connectors] + ); + + const onDescriptionChange = useCallback((newValue) => setFieldValue('description', newValue), [ setFieldValue, ]); @@ -106,15 +146,145 @@ export const Create = React.memo(() => { const onSubmit = useCallback(async () => { const { isValid, data } = await submit(); if (isValid) { - // `postCase`'s type is incorrect, it actually returns a promise - await postCase(data); + const { connectorId: dataConnectorId, ...dataWithoutConnectorId } = data; + const caseConnector = getConnectorById(dataConnectorId, connectors); + const connectorToUpdate = caseConnector + ? normalizeActionConnector(caseConnector, fields) + : getNoneConnector(); + + await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate }); } - }, [submit, postCase]); + }, [submit, postCase, fields, connectors]); const handleSetIsCancel = useCallback(() => { history.push('/'); }, [history]); + const firstStep = useMemo( + () => ({ + title: i18n.STEP_ONE_TITLE, + children: ( + <> + + + + + {({ tags: anotherTags }) => { + const current: string[] = options.map((opt) => opt.label); + const newOptions = anotherTags.reduce((acc: string[], item: string) => { + if (!acc.includes(item)) { + return [...acc, item]; + } + return acc; + }, current); + if (!isEqual(current, newOptions)) { + setOptions( + newOptions.map((label: string) => ({ + label, + })) + ); + } + return null; + }} + + + + + ), + }} + /> + + + ), + }), + [isLoading, options, handleCursorChange, handleTimelineClick, handleOnTimelineChange] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.STEP_TWO_TITLE, + children: ( + + + + + + + + + + + + + ), + }), + [ + connector, + connectors, + currentConnectorId, + fields, + isLoading, + isLoadingConnectors, + onChangeConnector, + ] + ); + + const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]); + if (caseData != null && caseData.id) { history.push(getCaseDetailsUrl({ id: caseData.id })); return null; @@ -124,72 +294,7 @@ export const Create = React.memo(() => { {isLoading && }
- - - - - - - ), - }} - /> - - - {({ tags: anotherTags }) => { - const current: string[] = options.map((opt) => opt.label); - const newOptions = anotherTags.reduce((acc: string[], item: string) => { - if (!acc.includes(item)) { - return [...acc, item]; - } - return acc; - }, current); - if (!isEqual(current, newOptions)) { - setOptions( - newOptions.map((label: string) => ({ - label, - })) - ); - } - return null; - }} - + = { +export type FormProps = Omit & { connectorId: string }; + +export const schema: FormSchema = { title: { type: FIELD_TYPES.TEXT, label: i18n.NAME, @@ -37,4 +39,9 @@ export const schema: FormSchema = { ], }, tags: schemaTags, + connectorId: { + type: FIELD_TYPES.SUPER_SELECT, + label: i18n.CONNECTORS, + defaultValue: 'none', + }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts new file mode 100644 index 0000000000000..222f8913b3fbd --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../translations'; + +export const STEP_ONE_TITLE = i18n.translate( + 'xpack.securitySolution.components.create.stepOneTitle', + { + defaultMessage: 'Case fields', + } +); + +export const STEP_TWO_TITLE = i18n.translate( + 'xpack.securitySolution.components.create.stepTwoTitle', + { + defaultMessage: 'External incident management system fields', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/edit_connector/helpers.ts new file mode 100644 index 0000000000000..34d75b9f3d339 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/helpers.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CaseUserActions } from '../../containers/types'; + +export const getConnectorFieldsFromUserActions = (id: string, userActions: CaseUserActions[]) => { + try { + for (const action of [...userActions].reverse()) { + if (action.actionField.length === 1 && action.actionField[0] === 'connector') { + if (action.oldValue && action.newValue) { + const oldValue = JSON.parse(action.oldValue); + const newValue = JSON.parse(action.newValue); + + if (newValue.id === id) { + return newValue.fields; + } + + if (oldValue.id === id) { + return oldValue.fields; + } + } + } + } + + return null; + } catch { + return null; + } +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx index 12d549a2f71a9..25e2a19298c5e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.test.tsx @@ -12,10 +12,12 @@ import { getFormMock, useFormMock } from '../__mock__/form'; import { TestProviders } from '../../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; import { waitFor } from '@testing-library/react'; +import { caseUserActions } from '../../containers/mock'; jest.mock( '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form' ); + const onSubmit = jest.fn(); const defaultProps = { connectors: connectorsMock, @@ -23,22 +25,26 @@ const defaultProps = { isLoading: false, onSubmit, selectedConnector: 'none', + caseFields: null, + userActions: caseUserActions, }; describe('EditConnector ', () => { const sampleConnector = '123'; - const formHookMock = getFormMock({ connector: sampleConnector }); + const formHookMock = getFormMock({ connectorId: sampleConnector }); beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); useFormMock.mockImplementation(() => ({ form: formHookMock })); }); - it('Renders no connector, and then edit', () => { + + it('Renders no connector, and then edit', async () => { const wrapper = mount( ); + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); expect( wrapper.find(`span[data-test-subj="dropdown-connector-no-connector"]`).last().exists() @@ -46,8 +52,8 @@ describe('EditConnector ', () => { wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); - wrapper.update(); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); + await waitFor(() => wrapper.update()); expect(wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().exists()).toBeTruthy(); }); @@ -58,10 +64,11 @@ describe('EditConnector ', () => { ); + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); wrapper.update(); expect(wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().exists()).toBeTruthy(); @@ -79,10 +86,11 @@ describe('EditConnector ', () => { ); + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); wrapper.update(); expect(wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().exists()).toBeTruthy(); @@ -90,7 +98,7 @@ describe('EditConnector ', () => { wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); - expect(formHookMock.setFieldValue).toHaveBeenCalledWith('connector', 'none'); + expect(formHookMock.setFieldValue).toHaveBeenCalledWith('connectorId', 'none'); }); }); @@ -103,29 +111,32 @@ describe('EditConnector ', () => { ); + wrapper.find('[data-test-subj="connector-edit"] button').simulate('click'); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); - wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click'); + wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click'); wrapper.update(); wrapper.find(`[data-test-subj="edit-connectors-cancel"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); expect(formHookMock.setFieldValue).toBeCalledWith( - 'connector', + 'connectorId', defaultProps.selectedConnector ); }); }); - it('Renders loading spinner', () => { + it('Renders loading spinner', async () => { const props = { ...defaultProps, isLoading: true }; const wrapper = mount( ); - expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy(); + await waitFor(() => + expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy() + ); }); }); 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 94d694a9e107a..f3fb5675d4261 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 @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useReducer } from 'react'; +import deepEqual from 'fast-deep-equal'; import { EuiText, EuiHorizontalRule, @@ -13,80 +14,192 @@ import { EuiButton, EuiButtonEmpty, EuiLoadingSpinner, + EuiButtonIcon, } from '@elastic/eui'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { noop } from 'lodash/fp'; -import * as i18n from '../../translations'; import { Form, UseField, useForm } from '../../../shared_imports'; -import { schema } from './schema'; +import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; import { ConnectorSelector } from '../connector_selector/form'; -import { Connector } from '../../../../../case/common/api/cases'; +import { ActionConnector } from '../../../../../case/common/api/cases'; +import { SettingFieldsForm } from '../settings/fields_form'; +import { getConnectorById } from '../configure_cases/utils'; +import { CaseUserActions } from '../../containers/types'; +import { schema } from './schema'; +import { getConnectorFieldsFromUserActions } from './helpers'; +import * as i18n from './translations'; interface EditConnectorProps { - connectors: Connector[]; + caseFields: ConnectorTypeFields['fields']; + connectors: ActionConnector[]; disabled?: boolean; isLoading: boolean; - onSubmit: (a: string, onSuccess: () => void, onError: () => void) => void; + onSubmit: ( + connectorId: string, + connectorFields: ConnectorTypeFields['fields'], + onError: () => void, + onSuccess: () => void + ) => void; selectedConnector: string; + userActions: CaseUserActions[]; } const MyFlexGroup = styled(EuiFlexGroup)` - ${({ theme }) => css` + ${({ theme }) => ` margin-top: ${theme.eui.euiSizeM}; p { font-size: ${theme.eui.euiSizeM}; } `} `; +const DisappearingFlexItem = styled(EuiFlexItem)` + ${({ $isHidden }: { $isHidden: boolean }) => + $isHidden && + ` + margin: 0 !important; + `} +`; + +interface State { + currentConnector: ActionConnector | null; + fields: ConnectorTypeFields['fields']; + editConnector: boolean; +} + +type Action = + | { type: 'SET_CURRENT_CONNECTOR'; payload: State['currentConnector'] } + | { type: 'SET_FIELDS'; payload: State['fields'] } + | { type: 'SET_EDIT_CONNECTOR'; payload: State['editConnector'] }; +const editConnectorReducer = (state: State, action: Action) => { + switch (action.type) { + case 'SET_CURRENT_CONNECTOR': + return { + ...state, + currentConnector: action.payload, + }; + case 'SET_FIELDS': + return { + ...state, + fields: action.payload, + }; + case 'SET_EDIT_CONNECTOR': + return { + ...state, + editConnector: action.payload, + }; + default: + return state; + } +}; + +const initialState = { + currentConnector: null, + fields: null, + editConnector: false, +}; export const EditConnector = React.memo( ({ + caseFields, connectors, disabled = false, isLoading, onSubmit, selectedConnector, + userActions, }: EditConnectorProps) => { - const initialState: { - connectors: Connector[]; - connector: string | undefined; - } = { - connectors, - connector: undefined, - }; const { form } = useForm({ - defaultValue: initialState, + defaultValue: { connectorId: selectedConnector }, options: { stripEmptyFields: false }, schema, }); + const { setFieldValue, submit } = form; - const [connectorHasChanged, setConnectorHasChanged] = useState(false); + + const [{ currentConnector, fields, editConnector }, dispatch] = useReducer( + editConnectorReducer, + { ...initialState, fields: caseFields } + ); + const onChangeConnector = useCallback( - (connectorId) => { - setConnectorHasChanged(selectedConnector !== connectorId); + (newConnectorId) => { + // Init + if (currentConnector == null) { + dispatch({ + type: 'SET_CURRENT_CONNECTOR', + payload: getConnectorById(newConnectorId, connectors), + }); + } + // change connect on dropdown action + else if (currentConnector.id !== newConnectorId) { + dispatch({ + type: 'SET_CURRENT_CONNECTOR', + payload: getConnectorById(newConnectorId, connectors), + }); + dispatch({ + type: 'SET_FIELDS', + payload: getConnectorFieldsFromUserActions(newConnectorId, userActions ?? []), + }); + } else if (fields === null) { + dispatch({ + type: 'SET_FIELDS', + payload: getConnectorFieldsFromUserActions(newConnectorId, userActions ?? []), + }); + } + }, + [currentConnector, fields, userActions, connectors] + ); + + const onFieldsChange = useCallback( + (newFields) => { + if (!deepEqual(newFields, fields)) { + dispatch({ + type: 'SET_FIELDS', + payload: newFields, + }); + } }, - [selectedConnector] + [fields, dispatch] ); const onError = useCallback(() => { - setFieldValue('connector', selectedConnector); - setConnectorHasChanged(false); - }, [setFieldValue, selectedConnector]); + setFieldValue('connectorId', selectedConnector); + dispatch({ + type: 'SET_EDIT_CONNECTOR', + payload: false, + }); + }, [dispatch, setFieldValue, selectedConnector]); const onCancelConnector = useCallback(() => { - setFieldValue('connector', selectedConnector); - setConnectorHasChanged(false); - }, [selectedConnector, setFieldValue]); + setFieldValue('connectorId', selectedConnector); + dispatch({ + type: 'SET_FIELDS', + payload: caseFields, + }); + dispatch({ + type: 'SET_EDIT_CONNECTOR', + payload: false, + }); + }, [dispatch, selectedConnector, setFieldValue, caseFields]); const onSubmitConnector = useCallback(async () => { const { isValid, data: newData } = await submit(); - if (isValid && newData.connector) { - onSubmit(newData.connector, noop, onError); - setConnectorHasChanged(false); + if (isValid && newData.connectorId) { + onSubmit(newData.connectorId, fields, onError, noop); + dispatch({ + type: 'SET_EDIT_CONNECTOR', + payload: false, + }); } - }, [submit, onSubmit, onError]); + }, [dispatch, submit, fields, onSubmit, onError]); + const onEditClick = useCallback(() => { + dispatch({ + type: 'SET_EDIT_CONNECTOR', + payload: true, + }); + }, [dispatch]); return ( @@ -94,32 +207,59 @@ export const EditConnector = React.memo(

{i18n.CONNECTORS}

{isLoading && } + {!isLoading && !editConnector && ( + + + + )}
- + - +
+
+ + {(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined. + !(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted. + !editConnector && ( + + {i18n.NO_CONNECTOR} + + )} + - {connectorHasChanged && ( + {editConnector && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx index cdc50c7d28e4f..c58748b5a2875 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/schema.tsx @@ -3,10 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { FormSchema } from '../../../shared_imports'; -export const schema: FormSchema = { - connector: { - defaultValue: 'none', +import { FormSchema, FIELD_TYPES } from '../../../shared_imports'; + +export interface FormProps { + connectorId: string; +} + +export const schema: FormSchema = { + connectorId: { + type: FIELD_TYPES.SUPER_SELECT, }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts b/x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts new file mode 100644 index 0000000000000..d5c86e229f40c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/translations.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../translations'; + +export const EDIT_CONNECTOR_ARIA = i18n.translate( + 'xpack.securitySolution.case.editConnector.editConnectorLinkAria', + { + defaultMessage: 'click to edit connector', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx b/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx new file mode 100644 index 0000000000000..60b471b1a99c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; +import styled from 'styled-components'; + +import { connectorsConfiguration } from '../../../common/lib/connectors/config'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; + +interface ConnectorCardProps { + connectorType: ConnectorTypes; + title: string; + listItems: Array<{ title: string; description: React.ReactNode }>; + isLoading: boolean; +} + +const StyledText = styled.span` + span { + display: block; + } +`; + +const ConnectorCardDisplay: React.FC = ({ + connectorType, + title, + listItems, + isLoading, +}) => { + const description = useMemo( + () => ( + + {listItems.length > 0 && + listItems.map((item, i) => ( + + {`${item.title}: `} + {item.description} + + ))} + + ), + [listItems] + ); + const icon = useMemo( + () => , + [connectorType] + ); + return ( + <> + {isLoading && } + {!isLoading && ( + + )} + + ); +}; + +export const ConnectorCard = memo(ConnectorCardDisplay); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx b/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx new file mode 100644 index 0000000000000..87536b62747e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, Suspense, useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { CaseSettingsConnector, SettingFieldsProps } from './types'; +import { getCaseSettings } from '.'; +import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; + +interface Props extends Omit, 'connector'> { + connector: CaseSettingsConnector | null; +} + +const SettingFieldsFormComponent: React.FC = ({ connector, isEdit, onChange, fields }) => { + const { caseSettingsRegistry } = getCaseSettings(); + + const onFieldsChange = useCallback( + (newFields) => { + onChange(newFields); + }, + [onChange] + ); + + if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') { + return null; + } + + const { caseSettingFieldsComponent: FieldsComponent } = caseSettingsRegistry.get( + connector.actionTypeId + ); + + return ( + <> + {FieldsComponent != null ? ( + + + + + + } + > + + + ) : null} + + ); +}; + +export const SettingFieldsForm = memo(SettingFieldsFormComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/index.ts b/x-pack/plugins/security_solution/public/cases/components/settings/index.ts new file mode 100644 index 0000000000000..75918f674027b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CaseSettingsRegistry } from './types'; +import { createCaseSettingsRegistry } from './settings_registry'; +import { getCaseSetting as getJiraCaseSetting } from './jira'; +import { getCaseSetting as getResilientCaseSetting } from './resilient'; +import { getCaseSetting as getServiceNowCaseSetting } from './servicenow'; +import { + JiraFieldsType, + ServiceNowFieldsType, + ResilientFieldsType, +} from '../../../../../case/common/api/connectors'; + +interface GetCaseSettingReturn { + caseSettingsRegistry: CaseSettingsRegistry; +} + +class CaseSettings { + private caseSettingsRegistry: CaseSettingsRegistry; + + constructor() { + this.caseSettingsRegistry = createCaseSettingsRegistry(); + this.init(); + } + + private init() { + this.caseSettingsRegistry.register(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/jira/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts new file mode 100644 index 0000000000000..f6d404b9b08b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GetIssueTypesProps, GetFieldsByIssueTypeProps } from '../api'; +import { IssueTypes, Fields } from '../types'; + +const issueTypes = [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, +]; + +const fieldsByIssueType = { + summary: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, +}; + +export const getIssueTypes = async (props: GetIssueTypesProps): Promise<{ data: IssueTypes }> => + Promise.resolve({ data: issueTypes }); + +export const getFieldsByIssueType = async ( + props: GetFieldsByIssueTypeProps +): Promise<{ data: Fields }> => Promise.resolve({ data: fieldsByIssueType }); 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/settings/jira/api.test.ts new file mode 100644 index 0000000000000..d5474aaceaa48 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { getIssueTypes, getFieldsByIssueType, getIssues, getIssue } from './api'; + +const issueTypesResponse = { + data: { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }, + ], + }, +}; + +const fieldsResponse = { + data: { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + fields: { + summary: { fieldId: 'summary' }, + priority: { + fieldId: 'priority', + allowedValues: [ + { + name: 'Highest', + id: '1', + }, + { + name: 'High', + id: '2', + }, + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '4', + }, + { + name: 'Lowest', + id: '5', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, + }, + }, + ], + }, + ], + }, +}; + +const issueResponse = { + id: '10267', + key: 'RJ-107', + fields: { summary: 'Test title' }, +}; + +const issuesResponse = [issueResponse]; + +describe('Jira API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getIssueTypes', () => { + test('should call get issue types API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(issueTypesResponse); + const res = await getIssueTypes({ http, signal: abortCtrl.signal, connectorId: 'test' }); + + expect(res).toEqual(issueTypesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"issueTypes","subActionParams":{}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getFieldsByIssueType', () => { + test('should call get fields API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(fieldsResponse); + const res = await getFieldsByIssueType({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + id: '10006', + }); + + expect(res).toEqual(fieldsResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"fieldsByIssueType","subActionParams":{"id":"10006"}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getIssues', () => { + test('should call get fields API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(issuesResponse); + const res = await getIssues({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + title: 'test issue', + }); + + expect(res).toEqual(issuesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"issues","subActionParams":{"title":"test issue"}}}', + signal: abortCtrl.signal, + }); + }); + }); + + describe('getIssue', () => { + test('should call get fields API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(issuesResponse); + const res = await getIssue({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + id: 'RJ-107', + }); + + expect(res).toEqual(issuesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"issue","subActionParams":{"id":"RJ-107"}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts b/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts new file mode 100644 index 0000000000000..5aaa3fc38b102 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../../case/common/api'; +import { IssueTypes, Fields, Issues, Issue } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface GetIssueTypesProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; +} + +export async function getIssueTypes({ http, signal, connectorId }: GetIssueTypesProps) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'issueTypes', subActionParams: {} }, + }), + signal, + } + ); +} + +export interface GetFieldsByIssueTypeProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + id: string; +} + +export async function getFieldsByIssueType({ + http, + signal, + connectorId, + id, +}: GetFieldsByIssueTypeProps): Promise> { + return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'fieldsByIssueType', subActionParams: { id } }, + }), + signal, + }); +} + +export interface GetIssuesTypeProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + title: string; +} + +export async function getIssues({ + http, + signal, + connectorId, + title, +}: GetIssuesTypeProps): Promise> { + return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'issues', subActionParams: { title } }, + }), + signal, + }); +} + +export interface GetIssueTypeProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + id: string; +} + +export async function getIssue({ + http, + signal, + connectorId, + id, +}: GetIssueTypeProps): Promise> { + return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'issue', subActionParams: { id } }, + }), + signal, + }); +} 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/settings/jira/fields.test.tsx new file mode 100644 index 0000000000000..b476b88ea3db4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { omit } from 'lodash/fp'; + +import { connector } from '../mock'; +import { useGetIssueTypes } from './use_get_issue_types'; +import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import Fields from './fields'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_issue_types'); +jest.mock('./use_get_fields_by_issue_type'); + +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; + +describe('JiraParamsFields renders', () => { + const useGetIssueTypesResponse = { + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }; + + const useGetFieldsByIssueTypeResponse = { + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + labels: { allowedValues: [], defaultValue: {} }, + description: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '2', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, + }; + + const fields = { + issueType: '10006', + priority: 'High', + parent: null, + }; + + const onChange = jest.fn(); + + beforeEach(() => { + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + jest.clearAllMocks(); + }); + + test('all params fields are rendered', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('value')).toStrictEqual( + '10006' + ); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('value')).toStrictEqual( + 'High' + ); + }); + + test('it disabled the fields when loading issue types', () => { + useGetIssueTypesMock.mockReturnValue({ ...useGetIssueTypesResponse, isLoading: true }); + + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled') + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy(); + }); + + test('it disabled the fields when loading fields', () => { + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + isLoading: true, + }); + + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled') + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy(); + }); + + test('it hides the priority if not supported', () => { + const response = omit('fields.priority', useGetFieldsByIssueTypeResponse); + + useGetFieldsByIssueTypeMock.mockReturnValue(response); + + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().exists()).toBeFalsy(); + }); + + test('it sets issue type correctly', async () => { + const wrapper = mount(); + + wrapper + .find('select[data-test-subj="issueTypeSelect"]') + .first() + .simulate('change', { + target: { value: '10007' }, + }); + + expect(onChange).toHaveBeenCalledWith({ issueType: '10007', parent: null, priority: null }); + }); + + test('it sets priority correctly', async () => { + const wrapper = mount(); + + wrapper + .find('select[data-test-subj="prioritySelect"]') + .first() + .simulate('change', { + target: { value: '2' }, + }); + + expect(onChange).toHaveBeenCalledWith({ issueType: '10006', parent: null, priority: '2' }); + }); + + test('it resets priority when changing issue type', async () => { + const wrapper = mount(); + wrapper + .find('select[data-test-subj="issueTypeSelect"]') + .first() + .simulate('change', { + target: { value: '10007' }, + }); + + expect(onChange).toBeCalledWith({ issueType: '10007', parent: null, priority: null }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx new file mode 100644 index 0000000000000..b19c1bfdd3f03 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo } 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 { 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> = ({ + connector, + fields, + isEdit = true, + onChange, +}) => { + const { issueType = null, priority = null, parent = null } = fields ?? {}; + const { http, notifications } = useKibana().services; + + const handleIssueType = useCallback( + (issueTypeSelectOptions: Array<{ value: string; text: string }>) => { + if (issueType == null && issueTypeSelectOptions.length > 0) { + // if there is no issue type set in the edit view, set it to default + if (isEdit) { + onChange({ + issueType: issueTypeSelectOptions[0].value, + parent, + priority, + }); + } + } + }, + [isEdit, issueType, onChange, parent, priority] + ); + const { isLoading: isLoadingIssueTypes, issueTypes } = useGetIssueTypes({ + connector, + http, + toastNotifications: notifications.toasts, + handleIssueType, + }); + + const issueTypesSelectOptions = useMemo( + () => + issueTypes.map((type) => ({ + text: type.name ?? '', + value: type.id ?? '', + })), + [issueTypes] + ); + + const currentIssueType = useMemo(() => { + if (!issueType && issueTypesSelectOptions.length > 0) { + return issueTypesSelectOptions[0].value; + } else if ( + issueTypesSelectOptions.length > 0 && + !issueTypesSelectOptions.some(({ value }) => value === issueType) + ) { + return issueTypesSelectOptions[0].value; + } + return issueType; + }, [issueType, issueTypesSelectOptions]); + + const { isLoading: isLoadingFields, fields: fieldsByIssueType } = useGetFieldsByIssueType({ + connector, + http, + issueType: currentIssueType, + toastNotifications: notifications.toasts, + }); + + const hasPriority = useMemo(() => fieldsByIssueType.priority != null, [fieldsByIssueType]); + + const hasParent = useMemo(() => fieldsByIssueType.parent != null, [fieldsByIssueType]); + + const prioritiesSelectOptions = useMemo(() => { + const priorities = fieldsByIssueType.priority?.allowedValues ?? []; + return map( + (p) => ({ + text: p.name, + value: p.name, + }), + priorities + ); + }, [fieldsByIssueType]); + + const listItems = useMemo( + () => [ + ...(issueType != null && issueType.length > 0 + ? [ + { + title: i18n.ISSUE_TYPE, + description: issueTypes.find((issue) => issue.id === issueType)?.name ?? '', + }, + ] + : []), + ...(parent != null && parent.length > 0 + ? [ + { + title: i18n.PARENT_ISSUE, + description: parent, + }, + ] + : []), + ...(priority != null && priority.length > 0 + ? [ + { + title: i18n.PRIORITY, + description: priority, + }, + ] + : []), + ], + [issueType, issueTypes, parent, priority] + ); + + const onFieldChange = useCallback( + (key, value) => { + if (key === 'issueType') { + return onChange({ ...fields, issueType: value, priority: null, parent: null }); + } + return onChange({ + ...fields, + issueType: currentIssueType, + parent, + priority, + [key]: value, + }); + }, + [currentIssueType, fields, onChange, parent, priority] + ); + return isEdit ? ( + + + onFieldChange('issueType', e.target.value)} + options={issueTypesSelectOptions} + value={currentIssueType ?? ''} + /> + + + <> + {hasParent && ( + <> + + + + onFieldChange('parent', parentIssueKey)} + selectedValue={parent} + /> + + + + + + )} + {hasPriority && ( + <> + + + + onFieldChange('priority', e.target.value)} + options={prioritiesSelectOptions} + value={priority ?? ''} + /> + + + + + )} + + + ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { JiraSettingFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/index.ts b/x-pack/plugins/security_solution/public/cases/components/settings/jira/index.ts new file mode 100644 index 0000000000000..19516dad1fabd --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { lazy } from 'react'; + +import { CaseSetting } from '../types'; +import { JiraFieldsType } from '../../../../../../case/common/api/connectors'; +import * as i18n from './translations'; + +export * from './types'; + +export const getCaseSetting = (): CaseSetting => { + return { + id: '.jira', + caseSettingFieldsComponent: lazy(() => import('./fields')), + }; +}; + +export const fieldLabels = { + issueType: i18n.ISSUE_TYPE, + priority: i18n.PRIORITY, + parent: i18n.PARENT_ISSUE, +}; 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/settings/jira/search_issues.tsx new file mode 100644 index 0000000000000..367ed2001bd4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/search_issues.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useEffect, useCallback, useState, memo } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionConnector } from '../../../containers/types'; +import { useGetIssues } from './use_get_issues'; +import { useGetSingleIssue } from './use_get_single_issue'; +import * as i18n from './translations'; + +interface Props { + selectedValue: string | null; + actionConnector?: ActionConnector; + onChange: (parentIssueKey: string) => void; +} + +const SearchIssuesComponent: React.FC = ({ selectedValue, actionConnector, onChange }) => { + const [query, setQuery] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>>( + [] + ); + const [options, setOptions] = useState>>([]); + const { http, notifications } = useKibana().services; + + const { isLoading: isLoadingIssues, issues } = useGetIssues({ + http, + toastNotifications: notifications.toasts, + actionConnector, + query, + }); + + const { isLoading: isLoadingSingleIssue, issue: singleIssue } = useGetSingleIssue({ + http, + toastNotifications: notifications.toasts, + actionConnector, + id: selectedValue, + }); + + useEffect(() => setOptions(issues.map((issue) => ({ label: issue.title, value: issue.key }))), [ + issues, + ]); + + useEffect(() => { + if (isLoadingSingleIssue || singleIssue == null) { + return; + } + + const singleIssueAsOptions = [{ label: singleIssue.title, value: singleIssue.key }]; + setOptions(singleIssueAsOptions); + setSelectedOptions(singleIssueAsOptions); + }, [singleIssue, isLoadingSingleIssue]); + + const onSearchChange = useCallback((searchVal: string) => { + setQuery(searchVal); + }, []); + + const onChangeComboBox = useCallback( + (changedOptions) => { + setSelectedOptions(changedOptions); + onChange(changedOptions[0].value); + }, + [onChange] + ); + + const inputPlaceholder = useMemo( + (): string => + isLoadingIssues || isLoadingSingleIssue + ? i18n.SEARCH_ISSUES_LOADING + : i18n.SEARCH_ISSUES_PLACEHOLDER, + [isLoadingIssues, isLoadingSingleIssue] + ); + + return ( + + ); +}; + +export const SearchIssues = memo(SearchIssuesComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts b/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts new file mode 100644 index 0000000000000..54c46f064aa75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ISSUE_TYPES_API_ERROR = i18n.translate( + 'xpack.securitySolution.components.settings.jira.unableToGetIssueTypesMessage', + { + defaultMessage: 'Unable to get issue types', + } +); + +export const FIELDS_API_ERROR = i18n.translate( + 'xpack.securitySolution.components.settings.jira.unableToGetFieldsMessage', + { + defaultMessage: 'Unable to get fields', + } +); + +export const ISSUES_API_ERROR = i18n.translate( + 'xpack.securitySolution.components.settings.jira.unableToGetIssuesMessage', + { + defaultMessage: 'Unable to get issues', + } +); + +export const GET_ISSUE_API_ERROR = (id: string) => + i18n.translate('xpack.securitySolution.components.settings.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', + { + defaultMessage: 'Select parent issue', + } +); + +export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxPlaceholder', + { + defaultMessage: 'Select parent issue', + } +); + +export const SEARCH_ISSUES_LOADING = i18n.translate( + 'xpack.securitySolution.components.settings.jira.searchIssuesLoading', + { + defaultMessage: 'Loading...', + } +); + +export const PRIORITY = i18n.translate( + 'xpack.securitySolution.case.settings.jira.prioritySelectFieldLabel', + { + defaultMessage: 'Priority', + } +); + +export const ISSUE_TYPE = i18n.translate( + 'xpack.securitySolution.case.settings.jira.issueTypesSelectFieldLabel', + { + defaultMessage: 'Issue type', + } +); + +export const PARENT_ISSUE = i18n.translate( + 'xpack.securitySolution.case.settings.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/settings/jira/types.ts new file mode 100644 index 0000000000000..ec8c0b0004e28 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type IssueTypes = Array<{ id: string; name: string }>; +export interface Fields { + [key: string]: { + allowedValues: Array<{ name: string; id: string }> | []; + defaultValue: { name: string; id: string } | {}; + }; +} + +export interface Issue { + id: string; + key: string; + title: string; +} + +export type Issues = Issue[]; 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/settings/jira/use_get_fields_by_issue_type.test.tsx new file mode 100644 index 0000000000000..626f34b7e0d8f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetFieldsByIssueType, UseGetFieldsByIssueType } from './use_get_fields_by_issue_type'; +import * as api from './api'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetFieldsByIssueType', () => { + const { http, notifications } = useKibanaMock().services; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ http, toastNotifications: notifications.toasts, issueType: null }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, fields: {} }); + }); + }); + + test('does not fetch when issueType is not provided', async () => { + const spyOnGetFieldsByIssueType = jest.spyOn(api, 'getFieldsByIssueType'); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ + http, + toastNotifications: notifications.toasts, + connector, + issueType: null, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(spyOnGetFieldsByIssueType).not.toHaveBeenCalled(); + expect(result.current).toEqual({ isLoading: false, fields: {} }); + }); + }); + + test('fetch fields', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ + http, + toastNotifications: notifications.toasts, + connector, + issueType: 'Task', + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getFieldsByIssueType'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetFieldsByIssueType({ + http, + toastNotifications: notifications.toasts, + connector, + issueType: null, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, fields: {} }); + }); + }); +}); 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/settings/jira/use_get_fields_by_issue_type.tsx new file mode 100644 index 0000000000000..b3babc29ad534 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getFieldsByIssueType } from './api'; +import { Fields } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + issueType: string | null; + connector?: ActionConnector; +} + +export interface UseGetFieldsByIssueType { + fields: Fields; + isLoading: boolean; +} + +export const useGetFieldsByIssueType = ({ + http, + toastNotifications, + connector, + issueType, +}: Props): UseGetFieldsByIssueType => { + const [isLoading, setIsLoading] = useState(true); + const [fields, setFields] = useState({}); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!connector || !issueType) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + try { + const res = await getFieldsByIssueType({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + id: issueType, + }); + + if (!didCancel) { + setIsLoading(false); + setFields(res.data ?? {}); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, connector, issueType, toastNotifications]); + + return { + isLoading, + fields, + }; +}; 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/settings/jira/use_get_issue_types.test.tsx new file mode 100644 index 0000000000000..3d54bb1e49061 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetIssueTypes, UseGetIssueTypes } from './use_get_issue_types'; +import * as api from './api'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetIssueTypes', () => { + const { http, notifications } = useKibanaMock().services; + const handleIssueType = jest.fn(); + + beforeEach(() => jest.clearAllMocks()); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ http, toastNotifications: notifications.toasts, handleIssueType }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, issueTypes: [] }); + }); + }); + + test('fetch issue types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ + http, + toastNotifications: notifications.toasts, + connector, + handleIssueType, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }); + }); + }); + + test('handleIssueType is called', async () => { + await act(async () => { + const { waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ + http, + toastNotifications: notifications.toasts, + connector, + handleIssueType, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(handleIssueType).toHaveBeenCalledWith([ + { text: 'Task', value: '10006' }, + { text: 'Bug', value: '10007' }, + ]); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getIssueTypes'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIssueTypes({ + http, + toastNotifications: notifications.toasts, + connector, + handleIssueType, + }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, issueTypes: [] }); + }); + }); +}); 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/settings/jira/use_get_issue_types.tsx new file mode 100644 index 0000000000000..dc7f78d21bbbb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIssueTypes } from './api'; +import { IssueTypes } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; + handleIssueType: (options: Array<{ value: string; text: string }>) => void; +} + +export interface UseGetIssueTypes { + issueTypes: IssueTypes; + isLoading: boolean; +} + +export const useGetIssueTypes = ({ + http, + connector, + toastNotifications, + handleIssueType, +}: Props): UseGetIssueTypes => { + const [isLoading, setIsLoading] = useState(true); + const [issueTypes, setIssueTypes] = 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 getIssueTypes({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + }); + + if (!didCancel) { + setIsLoading(false); + const asOptions = (res.data ?? []).map((type) => ({ + text: type.name ?? '', + value: type.id ?? '', + })); + setIssueTypes(res.data ?? []); + handleIssueType(asOptions); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, connector, toastNotifications, handleIssueType]); + + return { + issueTypes, + isLoading, + }; +}; 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/settings/jira/use_get_issues.tsx new file mode 100644 index 0000000000000..0679f154b201c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, debounce } from 'lodash/fp'; +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIssues } from './api'; +import { Issues } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; + query: string | null; +} + +export interface UseGetIssues { + issues: Issues; + isLoading: boolean; +} + +export const useGetIssues = ({ + http, + actionConnector, + toastNotifications, + query, +}: Props): UseGetIssues => { + const [isLoading, setIsLoading] = useState(false); + const [issues, setIssues] = useState([]); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = debounce(500, async () => { + if (!actionConnector || isEmpty(query)) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + + try { + const res = await getIssues({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + title: query ?? '', + }); + + if (!didCancel) { + setIsLoading(false); + setIssues(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: error.message, + }); + } + } + }); + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, actionConnector, toastNotifications, query]); + + return { + issues, + isLoading, + }; +}; 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/settings/jira/use_get_single_issue.tsx new file mode 100644 index 0000000000000..9e1e731ee8aac --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIssue } from './api'; +import { Issue } from './types'; +import * as i18n from './translations'; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + id: string | null; + actionConnector?: ActionConnector; +} + +export interface UseGetSingleIssue { + issue: Issue | null; + isLoading: boolean; +} + +export const useGetSingleIssue = ({ + http, + toastNotifications, + actionConnector, + id, +}: Props): UseGetSingleIssue => { + const [isLoading, setIsLoading] = useState(false); + const [issue, setIssue] = useState(null); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!actionConnector || !id) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + try { + const res = await getIssue({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + id, + }); + + if (!didCancel) { + setIsLoading(false); + setIssue(res.data ?? null); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, actionConnector, id, toastNotifications]); + + return { + isLoading, + issue, + }; +}; 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 new file mode 100644 index 0000000000000..938335146dd9f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const connector = { + id: '123', + name: 'My connector', + actionTypeId: '.jira', + config: {}, + isPreconfigured: false, +}; 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/settings/resilient/__mocks__/api.ts new file mode 100644 index 0000000000000..28ac94dd4ad0c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.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; + * you may not use this file except in compliance with the Elastic License. + */ + +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 }); + +export const getSeverity = async (props: Props): Promise<{ data: ResilientSeverity }> => + Promise.resolve({ data: severity }); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts new file mode 100644 index 0000000000000..961df85226ebf --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../../case/common/api'; +import { ResilientIncidentTypes, ResilientSeverity } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface Props { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; +} + +export async function getIncidentTypes({ http, signal, connectorId }: Props) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'incidentTypes', subActionParams: {} }, + }), + signal, + } + ); +} + +export async function getSeverity({ http, signal, connectorId }: Props) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'severity', subActionParams: {} }, + }), + signal, + } + ); +} 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/settings/resilient/fields.test.tsx new file mode 100644 index 0000000000000..d1935e1e6cf33 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +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'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_incident_types'); +jest.mock('./use_get_severity'); + +const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; +const useGetSeverityMock = useGetSeverity as jest.Mock; + +describe('ResilientParamsFields renders', () => { + const useGetIncidentTypesResponse = { + isLoading: false, + incidentTypes: [ + { + id: 19, + name: 'Malware', + }, + { + id: 21, + name: 'Denial of Service', + }, + ], + }; + + const useGetSeverityResponse = { + isLoading: false, + severity: [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, + ], + }; + + const fields = { + severityCode: '6', + incidentTypes: ['19'], + }; + + const onChange = jest.fn(); + + beforeEach(() => { + useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); + useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + jest.clearAllMocks(); + }); + + test('all params fields are rendered', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('options')).toEqual( + [ + { label: 'Malware', value: '19' }, + { label: 'Denial of Service', value: '21' }, + ] + ); + + expect( + wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('selectedOptions') + ).toEqual([{ label: 'Malware', value: '19' }]); + + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual( + '6' + ); + }); + + test('it disabled the fields when loading incident types', () => { + useGetIncidentTypesMock.mockReturnValue({ ...useGetIncidentTypesResponse, isLoading: true }); + + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + + test('it disabled the fields when loading severity', () => { + useGetSeverityMock.mockReturnValue({ + ...useGetSeverityResponse, + isLoading: true, + }); + + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('disabled')).toBeTruthy(); + }); + + test('it sets issue type correctly', async () => { + const wrapper = mount(); + + await waitFor(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ value: '19', label: 'Denial of Service' }]); + }); + + expect(onChange).toHaveBeenCalledWith({ incidentTypes: ['19'], severityCode: '6' }); + }); + + test('it sets severity correctly', async () => { + const wrapper = mount(); + + wrapper + .find('select[data-test-subj="severitySelect"]') + .first() + .simulate('change', { + target: { value: '4' }, + }); + + expect(onChange).toHaveBeenCalledWith({ incidentTypes: ['19'], severityCode: '4' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx new file mode 100644 index 0000000000000..f3aa48c765d3c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useCallback, useEffect } from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFormRow, + EuiSelect, + EuiSelectOption, + EuiSpacer, +} from '@elastic/eui'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { SettingFieldsProps } from '../types'; + +import { useGetIncidentTypes } from './use_get_incident_types'; +import { useGetSeverity } from './use_get_severity'; + +import * as i18n from './translations'; +import { ConnectorTypes, ResilientFieldsType } from '../../../../../../case/common/api/connectors'; +import { ConnectorCard } from '../card'; + +const ResilientSettingFieldsComponent: React.FunctionComponent> = ({ isEdit = true, fields, connector, onChange }) => { + const { incidentTypes = null, severityCode = null } = fields ?? {}; + + const { http, notifications } = useKibana().services; + + const { + isLoading: isLoadingIncidentTypes, + incidentTypes: allIncidentTypes, + } = useGetIncidentTypes({ + http, + toastNotifications: notifications.toasts, + connector, + }); + + const { isLoading: isLoadingSeverity, severity } = useGetSeverity({ + http, + toastNotifications: notifications.toasts, + connector, + }); + + const severitySelectOptions: EuiSelectOption[] = useMemo( + () => + severity.map((s) => ({ + value: s.id.toString(), + text: s.name, + })), + [severity] + ); + + const incidentTypesComboBoxOptions: Array> = useMemo( + () => + allIncidentTypes + ? allIncidentTypes.map((type: { id: number; name: string }) => ({ + label: type.name, + value: type.id.toString(), + })) + : [], + [allIncidentTypes] + ); + const listItems = useMemo( + () => [ + ...(incidentTypes != null && incidentTypes.length > 0 + ? [ + { + title: i18n.INCIDENT_TYPES_LABEL, + description: allIncidentTypes + .filter((type) => incidentTypes.includes(type.id.toString())) + .map((type) => type.name) + .join(', '), + }, + ] + : []), + ...(severityCode != null && severityCode.length > 0 + ? [ + { + title: i18n.SEVERITY_LABEL, + description: + severity.find((severityObj) => severityObj.id.toString() === severityCode)?.name ?? + '', + }, + ] + : []), + ], + [incidentTypes, severityCode, allIncidentTypes, severity] + ); + + const onFieldChange = useCallback( + (key, value) => { + onChange({ + ...fields, + incidentTypes, + severityCode, + [key]: value, + }); + }, + [incidentTypes, severityCode, onChange, fields] + ); + + const selectedIncidentTypesComboBoxOptionsMemo = useMemo(() => { + const allIncidentTypesAsObject = allIncidentTypes.reduce( + (acc, type) => ({ ...acc, [type.id.toString()]: type.name }), + {} as Record + ); + return incidentTypes + ? incidentTypes + .map((type) => ({ + label: allIncidentTypesAsObject[type.toString()], + value: type.toString(), + })) + .filter((type) => type.label != null) + : []; + }, [allIncidentTypes, incidentTypes]); + + const onIncidentChange = useCallback( + (selectedOptions: Array<{ label: string; value?: string }>) => { + onFieldChange( + 'incidentTypes', + selectedOptions.map((selectedOption) => selectedOption.value ?? selectedOption.label) + ); + }, + [onFieldChange] + ); + + const onIncidentBlur = useCallback(() => { + if (!incidentTypes) { + onFieldChange('incidentTypes', []); + } + }, [incidentTypes, onFieldChange]); + + // We need to set them up at initialization + useEffect(() => { + onChange({ incidentTypes, severityCode }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return isEdit ? ( + + + + + + + onFieldChange('severityCode', e.target.value)} + options={severitySelectOptions} + value={severityCode ?? undefined} + /> + + + + ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ResilientSettingFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/index.ts b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/index.ts new file mode 100644 index 0000000000000..a06071756b1cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { lazy } from 'react'; + +import { CaseSetting } from '../types'; +import { ResilientFieldsType } from '../../../../../../case/common/api/connectors'; +import * as i18n from './translations'; + +export * from './types'; + +export const getCaseSetting = (): CaseSetting => { + return { + id: '.resilient', + caseSettingFieldsComponent: lazy(() => import('./fields')), + }; +}; + +export const fieldLabels = { + incidentTypes: i18n.INCIDENT_TYPES_LABEL, + severityCode: i18n.SEVERITY_LABEL, +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts new file mode 100644 index 0000000000000..c820cea518f2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const INCIDENT_TYPES_API_ERROR = i18n.translate( + 'xpack.securitySolution.case.settings.resilient.unableToGetIncidentTypesMessage', + { + defaultMessage: 'Unable to get incident types', + } +); + +export const SEVERITY_API_ERROR = i18n.translate( + 'xpack.securitySolution.case.settings.resilient.unableToGetSeverityMessage', + { + defaultMessage: 'Unable to get severity', + } +); + +export const INCIDENT_TYPES_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.case.settings.resilient.incidentTypesPlaceholder', + { + defaultMessage: 'Choose types', + } +); + +export const INCIDENT_TYPES_LABEL = i18n.translate( + 'xpack.securitySolution.case.settings.resilient.incidentTypesLabel', + { + defaultMessage: 'Incident Types', + } +); + +export const SEVERITY_LABEL = i18n.translate( + 'xpack.securitySolution.case.settings.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/settings/resilient/types.ts new file mode 100644 index 0000000000000..1788c1eda7ed9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/types.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type ResilientIncidentTypes = Array<{ id: number; name: string }>; +export type ResilientSeverity = ResilientIncidentTypes; 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/settings/resilient/use_get_incident_types.test.tsx new file mode 100644 index 0000000000000..b8ba6596308fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetIncidentTypes, UseGetIncidentTypes } from './use_get_incident_types'; +import * as api from './api'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetIncidentTypes', () => { + const { http, notifications } = useKibanaMock().services; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIncidentTypes({ http, toastNotifications: notifications.toasts }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, incidentTypes: [] }); + }); + }); + + test('fetch incident types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIncidentTypes({ + http, + toastNotifications: notifications.toasts, + connector, + }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + incidentTypes: [ + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, + ], + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getIncidentTypes'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetIncidentTypes({ http, toastNotifications: notifications.toasts, connector }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, incidentTypes: [] }); + }); + }); +}); 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/settings/resilient/use_get_incident_types.tsx new file mode 100644 index 0000000000000..4c5adbf6eaa26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getIncidentTypes } from './api'; +import * as i18n from './translations'; + +type IncidentTypes = Array<{ id: number; name: string }>; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; +} + +export interface UseGetIncidentTypes { + incidentTypes: IncidentTypes; + isLoading: boolean; +} + +export const useGetIncidentTypes = ({ + http, + toastNotifications, + connector, +}: Props): UseGetIncidentTypes => { + const [isLoading, setIsLoading] = useState(true); + const [incidentTypes, setIncidentTypes] = 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 getIncidentTypes({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + }); + + if (!didCancel) { + setIsLoading(false); + setIncidentTypes(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, connector, toastNotifications]); + + return { + incidentTypes, + isLoading, + }; +}; 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/settings/resilient/use_get_severity.test.tsx new file mode 100644 index 0000000000000..328bb76b78bfd --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { connector } from '../mock'; +import { useGetSeverity, UseGetSeverity } from './use_get_severity'; +import * as api from './api'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; + +describe('useGetSeverity', () => { + const { http, notifications } = useKibanaMock().services; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSeverity({ http, toastNotifications: notifications.toasts }) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ isLoading: true, severity: [] }); + }); + }); + + test('fetch severity', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSeverity({ http, toastNotifications: notifications.toasts, connector }) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + severity: [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, + ], + }); + }); + }); + + test('unhappy path', async () => { + const spyOnGetCaseConfigure = jest.spyOn(api, 'getSeverity'); + spyOnGetCaseConfigure.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetSeverity({ http, toastNotifications: notifications.toasts, connector }) + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ isLoading: false, severity: [] }); + }); + }); +}); 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/settings/resilient/use_get_severity.tsx new file mode 100644 index 0000000000000..1430a86611bfe --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { getSeverity } from './api'; +import * as i18n from './translations'; +import { ActionConnector } from '../../../containers/types'; + +type Severity = Array<{ id: number; name: string }>; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; +} + +export interface UseGetSeverity { + severity: Severity; + isLoading: boolean; +} + +export const useGetSeverity = ({ http, toastNotifications, connector }: Props): UseGetSeverity => { + const [isLoading, setIsLoading] = useState(true); + const [severity, setSeverity] = 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 getSeverity({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + }); + + if (!didCancel) { + setIsLoading(false); + setSeverity(res.data ?? []); + + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, connector, toastNotifications]); + + return { + severity, + isLoading, + }; +}; 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 new file mode 100644 index 0000000000000..34e41a6cee060 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { 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> = ({ 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 + }, []); + + return isEdit ? ( + + + { + onChange({ ...fields, urgency: e.target.value }); + }} + /> + + + + + + { + onChange({ ...fields, severity: e.target.value }); + }} + /> + + + + + { + onChange({ ...fields, 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 new file mode 100644 index 0000000000000..8eed2f55c6ecc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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 new file mode 100644 index 0000000000000..05139fc2c80a7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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 new file mode 100644 index 0000000000000..9cb68207b6d71 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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 new file mode 100644 index 0000000000000..d43063387ca17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/settings/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { 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/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index 4af781e3c31f4..a04450b3c4198 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -76,7 +76,6 @@ export const TagList = React.memo( ), [tagOptions] ); - return ( @@ -97,7 +96,7 @@ export const TagList = React.memo( )} - + {tags.length === 0 && !isEditTags &&

{i18n.NO_TAGS}

} {!isEditTags && } {isEditTags && ( 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 eb80eaff578f5..9bb79e88be138 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 @@ -10,10 +10,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; import '../../../common/mock/match_media'; import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../../common/mock'; + import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { basicPush, actionLicenses } from '../../containers/mock'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { connectorsMock } from '../../containers/configure/mock'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -51,8 +53,12 @@ describe('usePushToService', () => { }, }; const defaultArgs = { - caseConnectorId: mockConnector.id, - caseConnectorName: mockConnector.name, + connector: { + id: mockConnector.id, + name: mockConnector.name, + type: ConnectorTypes.servicenow, + fields: null, + }, caseId, caseServices, caseStatus: 'open', @@ -84,8 +90,12 @@ describe('usePushToService', () => { expect(postPushToService).toBeCalledWith({ caseId, caseServices, - connectorId: mockConnector.id, - connectorName: mockConnector.name, + connector: { + fields: null, + id: 'servicenow-1', + name: 'My Connector', + type: ConnectorTypes.servicenow, + }, updateCase, }); expect(result.current.pushCallouts).toBeNull(); @@ -143,7 +153,12 @@ describe('usePushToService', () => { usePushToService({ ...defaultArgs, connectors: [], - caseConnectorId: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }), { wrapper: ({ children }) => {children}, @@ -162,7 +177,12 @@ describe('usePushToService', () => { () => usePushToService({ ...defaultArgs, - caseConnectorId: 'none', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }), { wrapper: ({ children }) => {children}, @@ -181,7 +201,12 @@ describe('usePushToService', () => { () => usePushToService({ ...defaultArgs, - caseConnectorId: 'not-exist', + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, isValidConnector: false, }), { @@ -202,7 +227,12 @@ describe('usePushToService', () => { usePushToService({ ...defaultArgs, connectors: [], - caseConnectorId: 'not-exist', + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, isValidConnector: false, }), { 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 7b4a29098bdde..9ac0507d52c0b 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 @@ -16,7 +16,7 @@ import { getConfigureCasesUrl, useFormatUrl } from '../../../common/components/l import { CaseCallOut } from '../callout'; import { getLicenseError, getKibanaConfigError } from './helpers'; import * as i18n from './translations'; -import { Connector } from '../../../../../case/common/api/cases'; +import { CaseConnector, ActionConnector } from '../../../../../case/common/api'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; @@ -25,10 +25,9 @@ import { ErrorMessage } from '../callout/types'; export interface UsePushToService { caseId: string; caseStatus: string; - caseConnectorId: string; - caseConnectorName: string; + connector: CaseConnector; caseServices: CaseServices; - connectors: Connector[]; + connectors: ActionConnector[]; updateCase: (newCase: Case) => void; userCanCrud: boolean; isValidConnector: boolean; @@ -40,8 +39,7 @@ export interface ReturnUsePushToService { } export const usePushToService = ({ - caseConnectorId, - caseConnectorName, + connector, caseId, caseServices, caseStatus, @@ -57,16 +55,15 @@ export const usePushToService = ({ const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); const handlePushToService = useCallback(() => { - if (caseConnectorId != null && caseConnectorId !== 'none') { + if (connector.id != null && connector.id !== 'none') { postPushToService({ caseId, caseServices, - connectorId: caseConnectorId, - connectorName: caseConnectorName, + connector, updateCase, }); } - }, [caseId, caseServices, caseConnectorId, caseConnectorName, postPushToService, updateCase]); + }, [caseId, caseServices, connector, postPushToService, updateCase]); const goToConfigureCases = useCallback( (ev) => { @@ -81,7 +78,7 @@ export const usePushToService = ({ if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } - if (connectors.length === 0 && caseConnectorId === 'none' && !loadingLicense) { + if (connectors.length === 0 && connector.id === 'none' && !loadingLicense) { errors = [ ...errors, { @@ -106,7 +103,7 @@ export const usePushToService = ({ ), }, ]; - } else if (caseConnectorId === 'none' && !loadingLicense) { + } else if (connector.id === 'none' && !loadingLicense) { errors = [ ...errors, { @@ -156,7 +153,7 @@ export const usePushToService = ({ } return errors; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionLicense, caseStatus, connectors.length, caseConnectorId, loadingLicense, urlSearch]); + }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, urlSearch]); const pushToServiceButton = useMemo(() => { return ( @@ -170,15 +167,14 @@ export const usePushToService = ({ } isLoading={isLoading} > - {caseServices[caseConnectorId] - ? i18n.UPDATE_THIRD(caseConnectorName) - : i18n.PUSH_THIRD(caseConnectorName)} + {caseServices[connector.id] + ? i18n.UPDATE_THIRD(connector.name) + : i18n.PUSH_THIRD(connector.name)} ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - caseConnectorId, - caseConnectorName, + connector, connectors, errorsMsg, handlePushToService, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index 4e5c05f2f1404..6ac1ccb56f960 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { basicPush, getUserAction } from '../../containers/mock'; -import { getLabelTitle } from './helpers'; +import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers'; import * as i18n from '../case_view/translations'; import { mount } from 'enzyme'; import { connectorsMock } from '../../containers/configure/mock'; @@ -17,9 +17,7 @@ describe('User action tree helpers', () => { const action = getUserAction(['tags'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, - connectors, field: 'tags', - firstPush: false, }); const wrapper = mount(<>{result}); @@ -36,9 +34,7 @@ describe('User action tree helpers', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, - connectors, field: 'title', - firstPush: false, }); expect(result).toEqual( @@ -52,9 +48,7 @@ describe('User action tree helpers', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, - connectors, field: 'description', - firstPush: false, }); expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); @@ -64,9 +58,7 @@ describe('User action tree helpers', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ action, - connectors, field: 'status', - firstPush: false, }); expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); @@ -76,9 +68,7 @@ describe('User action tree helpers', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ action, - connectors, field: 'status', - firstPush: false, }); expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); @@ -88,9 +78,7 @@ describe('User action tree helpers', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, - connectors, field: 'comment', - firstPush: false, }); expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); @@ -98,12 +86,7 @@ describe('User action tree helpers', () => { it('label title generated for pushed incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); - const result: string | JSX.Element = getLabelTitle({ - action, - connectors, - field: 'pushed', - firstPush: true, - }); + const result: string | JSX.Element = getPushedServiceLabelTitle(action, true); const wrapper = mount(<>{result}); expect(wrapper.find(`[data-test-subj="pushed-label"]`).first().text()).toEqual( @@ -116,12 +99,7 @@ describe('User action tree helpers', () => { it('label title generated for needs update incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); - const result: string | JSX.Element = getLabelTitle({ - action, - connectors, - field: 'pushed', - firstPush: false, - }); + const result: string | JSX.Element = getPushedServiceLabelTitle(action, false); const wrapper = mount(<>{result}); expect(wrapper.find(`[data-test-subj="pushed-label"]`).first().text()).toEqual( @@ -132,22 +110,45 @@ describe('User action tree helpers', () => { ); }); - it('label title generated for update connector', () => { - const action = getUserAction(['connector_id'], 'update'); - const result: string | JSX.Element = getLabelTitle({ + it('label title generated for update connector - change connector', () => { + const action = { + ...getUserAction(['connector'], 'update'), + oldValue: JSON.stringify({ id: 'servicenow-1' }), + newValue: JSON.stringify({ id: 'resilient-2' }), + }; + const result: string | JSX.Element = getConnectorLabelTitle({ action, connectors, - field: 'tags', - firstPush: false, }); - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="ua-tags-label"]`).first().text()).toEqual( - ` ${i18n.TAGS.toLowerCase()}` - ); + expect(result).toEqual('selected My Connector 2 as incident management system'); + }); - expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( - action.newValue - ); + it('label title generated for update connector - change connector to none', () => { + const action = { + ...getUserAction(['connector'], 'update'), + oldValue: JSON.stringify({ id: 'servicenow-1' }), + newValue: JSON.stringify({ id: 'none' }), + }; + const result: string | JSX.Element = getConnectorLabelTitle({ + action, + connectors, + }); + + expect(result).toEqual('removed external incident management system'); + }); + + it('label title generated for update connector - field change', () => { + const action = { + ...getUserAction(['connector'], 'update'), + oldValue: JSON.stringify({ id: 'servicenow-1' }), + newValue: JSON.stringify({ id: 'servicenow-1' }), + }; + const result: string | JSX.Element = getConnectorLabelTitle({ + action, + connectors, + }); + + expect(result).toEqual('changed connector field'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index 4d8bb9ba078e5..0ced285f9dcd9 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -4,34 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps } from '@elastic/eui'; import React from 'react'; -import { CaseFullExternalService, Connector } from '../../../../../case/common/api'; +import { CaseFullExternalService, ActionConnector } from '../../../../../case/common/api'; import { CaseUserActions } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; -import * as i18n from '../case_view/translations'; +import { parseString } from '../../containers/utils'; import { Tags } from '../tag_list/tags'; +import * as i18n from '../case_view/translations'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; +import { UserActionTimestamp } from './user_action_timestamp'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; interface LabelTitle { action: CaseUserActions; - connectors: Connector[]; field: string; - firstPush: boolean; } -export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTitle) => { +export const getLabelTitle = ({ action, field }: LabelTitle) => { if (field === 'tags') { return getTagsLabelTitle(action); } else if (field === 'title' && action.action === 'update') { return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ action.newValue }"`; - } else if (field === 'connector_id' && action.action === 'update') { - const newConnector = connectors.find((c) => c.id === action.newValue); - return action.newValue != null && action.newValue !== 'none' && newConnector != null - ? i18n.SELECTED_THIRD_PARTY(newConnector.name) - : i18n.REMOVED_THIRD_PARTY; } else if (field === 'description' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; } else if (field === 'status' && action.action === 'update') { @@ -40,12 +38,37 @@ export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTit } ${i18n.CASE}`; } else if (field === 'comment' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; - } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { - return getPushedServiceLabelTitle(action, firstPush); } + return ''; }; +export const getConnectorLabelTitle = ({ + action, + connectors, +}: { + action: CaseUserActions; + connectors: ActionConnector[]; +}) => { + const oldValue = parseString(`${action.oldValue}`); + const newValue = parseString(`${action.newValue}`); + + if (oldValue === null || newValue === null) { + return ''; + } + + // Connector changed + if (oldValue.id !== newValue.id) { + const newConnector = connectors.find((c) => c.id === newValue.id); + return newValue.id != null && newValue.id !== 'none' && newConnector != null + ? i18n.SELECTED_THIRD_PARTY(newConnector.name) + : i18n.REMOVED_THIRD_PARTY; + } else { + // Field changed + return i18n.CHANGED_CONNECTOR_FIELD; + } +}; + const getTagsLabelTitle = (action: CaseUserActions) => { const tags = action.newValue != null ? action.newValue.split(',') : []; @@ -62,7 +85,7 @@ const getTagsLabelTitle = (action: CaseUserActions) => { ); }; -const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { +export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; return ( @@ -87,7 +110,7 @@ export const getPushInfo = ( ) => parsedValue != null ? { - firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + firstPush: caseServices[parsedValue.connector_id]?.firstPushIndex === index, parsedConnectorId: parsedValue.connector_id, parsedConnectorName: parsedValue.connector_name, } @@ -96,3 +119,37 @@ export const getPushInfo = ( parsedConnectorId: 'none', parsedConnectorName: 'none', }; + +export const getUpdateAction = ({ + action, + label, + handleOutlineComment, +}: { + action: CaseUserActions; + label: string | JSX.Element; + handleOutlineComment: (id: string) => void; +}): EuiCommentProps => ({ + username: ( + + ), + type: 'update', + event: label, + 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, + timestamp: , + timelineIcon: action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', + actions: ( + + + + + {action.action === 'update' && action.commentId != null && ( + + + + )} + + ), +}); 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 bada15294de09..1967402fd81e0 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 @@ -22,24 +22,27 @@ 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 { Connector } from '../../../../../case/common/api/cases'; +import { ActionConnector } from '../../../../../case/common/api/cases'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; -import { getLabelTitle, getPushInfo } from './helpers'; +import { + getConnectorLabelTitle, + getLabelTitle, + getPushedServiceLabelTitle, + getPushInfo, + getUpdateAction, +} from './helpers'; import { UserActionAvatar } from './user_action_avatar'; import { UserActionMarkdown } from './user_action_markdown'; import { UserActionTimestamp } from './user_action_timestamp'; -import { UserActionCopyLink } from './user_action_copy_link'; -import { UserActionMoveToReference } from './user_action_move_to_reference'; import { UserActionUsername } from './user_action_username'; -import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; import { UserActionContentToolbar } from './user_action_content_toolbar'; export interface UserActionTreeProps { caseServices: CaseServices; caseUserActions: CaseUserActions[]; - connectors: Connector[]; + connectors: ActionConnector[]; data: Case; fetchUserActions: () => void; isLoadingDescription: boolean; @@ -258,6 +261,7 @@ export const UserActionTree = React.memo( () => caseUserActions.reduce( (comments, action, index) => { + // Comment creation if (action.commentId != null && action.action === 'create') { const comment = caseData.comments.find((c) => c.id === action.commentId); if (comment != null) { @@ -315,8 +319,14 @@ export const UserActionTree = React.memo( } } - if (action.actionField.length === 1) { - const myField = action.actionField[0]; + // Connectors + if (action.actionField.length === 1 && action.actionField[0] === 'connector') { + const label = getConnectorLabelTitle({ action, connectors }); + return [...comments, getUpdateAction({ action, label, handleOutlineComment })]; + } + + // Pushed information + if (action.actionField.length === 1 && action.actionField[0] === 'pushed') { const parsedValue = parseString(`${action.newValue}`); const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( caseServices, @@ -324,20 +334,15 @@ export const UserActionTree = React.memo( index ); - const labelTitle: string | JSX.Element = getLabelTitle({ - action, - field: myField, - firstPush, - connectors, - }); + const label = getPushedServiceLabelTitle(action, firstPush); const showTopFooter = action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex; + index === caseServices[parsedConnectorId]?.lastPushIndex; const showBottomFooter = action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex && + index === caseServices[parsedConnectorId]?.lastPushIndex && caseServices[parsedConnectorId].hasDataToPush; let footers: EuiCommentProps[] = []; @@ -370,39 +375,25 @@ export const UserActionTree = React.memo( return [ ...comments, - { - username: ( - - ), - type: 'update', - event: labelTitle, - 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, - timestamp: , - timelineIcon: - action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', - actions: ( - - - - - {action.action === 'update' && action.commentId != null && ( - - - - )} - - ), - }, + getUpdateAction({ action, label, handleOutlineComment }), ...footers, ]; } + // description, comments, tags + if ( + action.actionField.length === 1 && + ['title', 'description', 'comment', 'tags'].includes(action.actionField[0]) + ) { + const myField = action.actionField[0]; + const label: string | JSX.Element = getLabelTitle({ + action, + field: myField, + }); + + return [...comments, getUpdateAction({ action, label, handleOutlineComment })]; + } + return comments; }, [descriptionCommentListObj] diff --git a/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx b/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx index b89a7e8eefec3..b824619800035 100644 --- a/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/wrappers/index.tsx @@ -17,6 +17,7 @@ export const SectionWrapper = styled.div` box-sizing: content-box; margin: 0 auto; max-width: 1175px; + width: 100%; `; export const HeaderWrapper = styled.div` 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 68a0d2834242e..373202968f79b 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 @@ -51,6 +51,7 @@ import { import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import * as i18n from './translations'; +import { ConnectorTypes } from '../../../../case/common/api/connectors'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -374,6 +375,12 @@ describe('Case Configuration API', () => { description: 'description', tags: ['tag'], title: 'title', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }; test('check url, method, signal', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts index c3611f490708a..257cb171a4a9a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts @@ -7,14 +7,14 @@ import { CasesConfigurePatch, CasesConfigureRequest, - Connector, + ActionConnector, } from '../../../../../../case/common/api'; import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; import { connectorsMock, caseConfigurationCamelCaseResponseMock } from '../mock'; -export const fetchConnectors = async ({ signal }: ApiProps): Promise => +export const fetchConnectors = async ({ signal }: ApiProps): Promise => Promise.resolve(connectorsMock); export const getCaseConfigure = async ({ signal }: ApiProps): Promise => diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts index 11a293ef437fa..f9115963c745d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts @@ -12,6 +12,7 @@ import { caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, } from './mock'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -77,7 +78,7 @@ describe('Case Configuration API', () => { await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { body: - '{"connector_id":"123","connector_name":"My Connector","closure_type":"close-by-user"}', + '{"connector":{"id":"123","name":"My connector","type":".jira","fields":null},"closure_type":"close-by-user"}', method: 'POST', signal: abortCtrl.signal, }); @@ -96,9 +97,16 @@ describe('Case Configuration API', () => { }); test('check url, body, method, signal', async () => { - await patchCaseConfigure({ connector_id: '456', version: 'WzHJ12' }, abortCtrl.signal); + await patchCaseConfigure( + { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }, + abortCtrl.signal + ); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { - body: '{"connector_id":"456","version":"WzHJ12"}', + body: + '{"connector":{"id":"456","name":"My Connector 2","type":".none","fields":null},"version":"WzHJ12"}', method: 'PATCH', signal: abortCtrl.signal, }); @@ -106,7 +114,10 @@ describe('Case Configuration API', () => { test('happy path', async () => { const resp = await patchCaseConfigure( - { connector_id: '456', version: 'WzHJ12' }, + { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }, abortCtrl.signal ); expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts index 4b4b81460ebc2..647bc1b466674 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts @@ -6,7 +6,7 @@ import { isEmpty } from 'lodash/fp'; import { - Connector, + ActionConnector, CasesConfigurePatch, CasesConfigureResponse, CasesConfigureRequest, @@ -22,7 +22,7 @@ import { ApiProps } from '../types'; import { convertToCamelCase, decodeCaseConfigureResponse } from '../utils'; import { CaseConfigure } from './types'; -export const fetchConnectors = async ({ signal }: ApiProps): Promise => { +export const fetchConnectors = async ({ signal }: ApiProps): Promise => { const response = await KibanaServices.get().http.fetch(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`, { method: 'GET', signal, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index 2fc761f4dc429..83c9e6fa71c24 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -5,9 +5,10 @@ */ import { - Connector, + ActionConnector, CasesConfigureResponse, CasesConfigureRequest, + ConnectorTypes, } from '../../../../../case/common/api'; import { CaseConfigure, CasesConfigurationMapping } from './types'; @@ -28,7 +29,7 @@ export const mapping: CasesConfigurationMapping[] = [ actionType: 'append', }, ]; -export const connectorsMock: Connector[] = [ +export const connectorsMock: ActionConnector[] = [ { id: 'servicenow-1', actionTypeId: '.servicenow', @@ -43,16 +44,17 @@ export const connectorsMock: Connector[] = [ isPreconfigured: false, }, { - id: 'servicenow-2', - actionTypeId: '.servicenow', + id: 'resilient-2', + actionTypeId: '.resilient', name: 'My Connector 2', config: { - apiUrl: 'https://instance2.service-now.com', + apiUrl: 'https://test/', + orgId: '201', incidentConfiguration: { mapping: [ { source: 'title', - target: 'short_description', + target: 'name', actionType: 'overwrite', }, { @@ -67,7 +69,6 @@ export const connectorsMock: Connector[] = [ }, ], }, - isCaseOwned: true, }, isPreconfigured: false, }, @@ -104,8 +105,12 @@ export const connectorsMock: Connector[] = [ export const caseConfigurationResposeMock: CasesConfigureResponse = { created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, - connector_id: '123', - connector_name: 'My Connector', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, closure_type: 'close-by-pushing', updated_at: '2020-04-06T14:03:18.657Z', updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, @@ -113,16 +118,24 @@ export const caseConfigurationResposeMock: CasesConfigureResponse = { }; export const caseConfigurationMock: CasesConfigureRequest = { - connector_id: '123', - connector_name: 'My Connector', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, closure_type: 'close-by-user', }; export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { createdAt: '2020-04-06T13:03:18.657Z', createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, - connectorId: '123', - connectorName: 'My Connector', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: null, + }, closureType: 'close-by-pushing', updatedAt: '2020-04-06T14:03:18.657Z', updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts index ed95315c066dc..879ed5e7a367a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts @@ -10,11 +10,21 @@ import { CasesConfigurationMaps, CaseField, ClosureType, - Connector, ThirdPartyField, + CasesConfigure, + ActionConnector, + CaseConnector, } from '../../../../../case/common/api'; -export { ActionType, CasesConfigurationMaps, CaseField, ClosureType, Connector, ThirdPartyField }; +export { + ActionType, + CasesConfigurationMaps, + CaseField, + ClosureType, + ThirdPartyField, + ActionConnector, + CaseConnector, +}; export interface CasesConfigurationMapping { source: CaseField; @@ -25,8 +35,7 @@ export interface CasesConfigurationMapping { export interface CaseConfigure { createdAt: string; createdBy: ElasticUser; - connectorId: string; - connectorName: string; + connector: CasesConfigure['connector']; closureType: ClosureType; updatedAt: string; updatedBy: ElasticUser; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx index 2826e9a2c2e55..342cdd8b80284 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.test.tsx @@ -13,12 +13,17 @@ import { } from './use_configure'; import { mapping, caseConfigurationCamelCaseResponseMock } from './mock'; import * as api from './api'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; jest.mock('./api'); const configuration: ConnectorConfiguration = { - connectorId: '456', - connectorName: 'My Connector 2', + connector: { + id: '456', + name: 'My connector 2', + type: ConnectorTypes.none, + fields: null, + }, closureType: 'close-by-pushing', }; @@ -56,12 +61,10 @@ describe('useConfigure', () => { expect(result.current).toEqual({ ...initialState, closureType: caseConfigurationCamelCaseResponseMock.closureType, - connectorId: caseConfigurationCamelCaseResponseMock.connectorId, - connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + connector: caseConfigurationCamelCaseResponseMock.connector, currentConfiguration: { closureType: caseConfigurationCamelCaseResponseMock.closureType, - connectorId: caseConfigurationCamelCaseResponseMock.connectorId, - connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + connector: caseConfigurationCamelCaseResponseMock.connector, }, version: caseConfigurationCamelCaseResponseMock.version, firstLoad: true, @@ -155,9 +158,9 @@ describe('useConfigure', () => { result.current.persistCaseConfigure(configuration); - expect(result.current.connectorId).toEqual('123'); + expect(result.current.connector.id).toEqual('123'); await waitForNextUpdate(); - expect(result.current.connectorId).toEqual('456'); + expect(result.current.connector.id).toEqual('456'); }); }); @@ -179,9 +182,9 @@ describe('useConfigure', () => { result.current.persistCaseConfigure(configuration); - expect(result.current.connectorId).toEqual('123'); + expect(result.current.connector.id).toEqual('123'); await waitForNextUpdate(); - expect(result.current.connectorId).toEqual('456'); + expect(result.current.connector.id).toEqual('456'); }); }); @@ -239,12 +242,10 @@ describe('useConfigure', () => { expect(result.current).toEqual({ ...initialState, closureType: caseConfigurationCamelCaseResponseMock.closureType, - connectorId: caseConfigurationCamelCaseResponseMock.connectorId, - connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + connector: caseConfigurationCamelCaseResponseMock.connector, currentConfiguration: { closureType: caseConfigurationCamelCaseResponseMock.closureType, - connectorId: caseConfigurationCamelCaseResponseMock.connectorId, - connectorName: caseConfigurationCamelCaseResponseMock.connectorName, + connector: caseConfigurationCamelCaseResponseMock.connector, }, firstLoad: true, loading: false, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx index e89212036ec20..16b813d9f4336 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx @@ -13,15 +13,12 @@ import { displaySuccessToast, } from '../../../common/components/toasters'; import * as i18n from './translations'; -import { CasesConfigurationMapping, ClosureType } from './types'; +import { CasesConfigurationMapping, ClosureType, CaseConfigure, CaseConnector } from './types'; +import { ConnectorTypes } from '../../../../../case/common/api/connectors'; -interface Connector { - connectorId: string; - connectorName: string; -} -export interface ConnectorConfiguration extends Connector { - closureType: ClosureType; -} +export type ConnectorConfiguration = { connector: CaseConnector } & { + closureType: CaseConfigure['closureType']; +}; export interface State extends ConnectorConfiguration { currentConfiguration: ConnectorConfiguration; @@ -38,7 +35,7 @@ export type Action = } | { type: 'setConnector'; - connector: Connector; + connector: CaseConnector; } | { type: 'setLoading'; @@ -96,7 +93,7 @@ export const configureCasesReducer = (state: State, action: Action) => { case 'setConnector': { return { ...state, - ...action.connector, + connector: action.connector, }; } case 'setClosureType': { @@ -117,26 +114,30 @@ export const configureCasesReducer = (state: State, action: Action) => { }; export interface ReturnUseCaseConfigure extends State { - persistCaseConfigure: ({ - connectorId, - connectorName, - closureType, - }: ConnectorConfiguration) => unknown; + persistCaseConfigure: ({ connector, closureType }: ConnectorConfiguration) => unknown; refetchCaseConfigure: () => void; setClosureType: (closureType: ClosureType) => void; - setConnector: (connectorId: string, connectorName?: string) => void; + setConnector: (connector: CaseConnector) => void; setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; setMapping: (newMapping: CasesConfigurationMapping[]) => void; } export const initialState: State = { closureType: 'close-by-user', - connectorId: 'none', - connectorName: 'none', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, currentConfiguration: { closureType: 'close-by-user', - connectorId: 'none', - connectorName: 'none', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, }, firstLoad: false, loading: true, @@ -155,9 +156,9 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }); }, []); - const setConnector = useCallback((connectorId: string, connectorName?: string) => { + const setConnector = useCallback((connector: CaseConnector) => { dispatch({ - connector: { connectorId, connectorName: connectorName ?? '' }, + connector, type: 'setConnector', }); }, []); @@ -216,7 +217,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { const res = await getCaseConfigure({ signal: abortCtrl.signal }); if (!didCancel) { if (res != null) { - setConnector(res.connectorId, res.connectorName); + setConnector(res.connector); if (setClosureType != null) { setClosureType(res.closureType); } @@ -227,8 +228,9 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { if (setCurrentConfiguration != null) { setCurrentConfiguration({ closureType: res.closureType, - connectorId: res.connectorId, - connectorName: res.connectorName, + connector: { + ...res.connector, + }, }); } } @@ -257,15 +259,14 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }, [state.firstLoad]); const persistCaseConfigure = useCallback( - async ({ connectorId, connectorName, closureType }: ConnectorConfiguration) => { + async ({ connector, closureType }: ConnectorConfiguration) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { try { setPersistLoading(true); const connectorObj = { - connector_id: connectorId, - connector_name: connectorName, + connector, closure_type: closureType, }; const res = @@ -279,16 +280,17 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { abortCtrl.signal ); if (!didCancel) { - setConnector(res.connectorId, res.connectorName); + setConnector(res.connector); if (setClosureType) { setClosureType(res.closureType); } setVersion(res.version); if (setCurrentConfiguration != null) { setCurrentConfiguration({ - connectorId: res.connectorId, closureType: res.closureType, - connectorName: res.connectorName, + connector: { + ...res.connector, + }, }); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx index 0d6b6acfd9065..a502ae8a4d478 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.test.tsx @@ -5,7 +5,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { useConnectors, ReturnConnectors } from './use_connectors'; +import { useConnectors, UseConnectorsResponse } from './use_connectors'; import { connectorsMock } from './mock'; import * as api from './api'; @@ -19,7 +19,7 @@ describe('useConnectors', () => { test('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result, waitForNextUpdate } = renderHook(() => useConnectors() ); await waitForNextUpdate(); @@ -33,7 +33,7 @@ describe('useConnectors', () => { test('fetch connectors', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result, waitForNextUpdate } = renderHook(() => useConnectors() ); await waitForNextUpdate(); @@ -49,7 +49,7 @@ describe('useConnectors', () => { test('refetch connectors', async () => { const spyOnfetchConnectors = jest.spyOn(api, 'fetchConnectors'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result, waitForNextUpdate } = renderHook(() => useConnectors() ); await waitForNextUpdate(); @@ -61,7 +61,7 @@ describe('useConnectors', () => { test('set isLoading to true when refetching connectors', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result, waitForNextUpdate } = renderHook(() => useConnectors() ); await waitForNextUpdate(); @@ -79,7 +79,7 @@ describe('useConnectors', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => + const { result, waitForNextUpdate } = renderHook(() => useConnectors() ); await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx index 812e580ad653f..b1783cc0af75f 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx @@ -9,18 +9,18 @@ import { useState, useEffect, useCallback } from 'react'; import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; import * as i18n from '../translations'; import { fetchConnectors } from './api'; -import { Connector } from './types'; +import { ActionConnector } from './types'; -export interface ReturnConnectors { +export interface UseConnectorsResponse { loading: boolean; - connectors: Connector[]; + connectors: ActionConnector[]; refetchConnectors: () => void; } -export const useConnectors = (): ReturnConnectors => { +export const useConnectors = (): UseConnectorsResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); - const [connectors, setConnectors] = useState([]); + const [connectors, setConnectors] = useState([]); const refetchConnectors = useCallback(() => { let didCancel = false; 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 6437f21209377..218ed77399df0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -19,6 +19,7 @@ import { CasesFindResponse, } from '../../../../case/common/api/cases'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; +import { ConnectorTypes } from '../../../../case/common/api/connectors'; export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; @@ -58,7 +59,12 @@ export const basicCase: Case = { comments: [basicComment], createdAt: basicCreatedAt, createdBy: elasticUser, - connectorId: '123', + connector: { + id: '123', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, description: 'Security banana Issue', externalService: null, status: 'open', @@ -199,7 +205,12 @@ export const basicCaseSnake: CaseResponse = { closed_at: null, closed_by: null, comments: [basicCommentSnake], - connector_id: '123', + connector: { + id: '123', + name: 'My Connector', + type: ConnectorTypes.none, + fields: null, + }, created_at: basicCreatedAt, created_by: elasticUserSnake, external_service: null, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index 648276cbc3c41..df3e75449b627 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User, UserActionField, UserAction } from '../../../../case/common/api'; +import { User, UserActionField, UserAction, CaseConnector } from '../../../../case/common/api'; + +export { CaseConnector, ActionConnector } from '../../../../case/common/api'; export interface Comment { id: string; @@ -43,7 +45,7 @@ export interface Case { closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - connectorId: string; + connector: CaseConnector; createdAt: string; createdBy: ElasticUser; description: string; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index ea4da41151993..7072363c1185d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -10,6 +10,7 @@ import { Case } from './types'; import * as i18n from './translations'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCase } from './api'; +import { getNoneConnector } from '../components/configure_cases/utils'; interface CaseState { data: Case; @@ -59,7 +60,7 @@ export const initialData: Case = { closedBy: null, createdAt: '', comments: [], - connectorId: 'none', + connector: { ...getNoneConnector(), fields: null }, createdBy: { username: '', }, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx index 1603beddbb1dc..b00df5524c8b5 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.test.tsx @@ -33,7 +33,7 @@ describe('useGetCaseUserActions', () => { it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCase.id, basicCase.connectorId) + useGetCaseUserActions(basicCase.id, basicCase.connector.id) ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -48,7 +48,7 @@ describe('useGetCaseUserActions', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCase.id, basicCase.connectorId) + useGetCaseUserActions(basicCase.id, basicCase.connector.id) ); await waitForNextUpdate(); @@ -61,7 +61,7 @@ describe('useGetCaseUserActions', () => { it('returns proper state on getCaseUserActions', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCase.id, basicCase.connectorId) + useGetCaseUserActions(basicCase.id, basicCase.connector.id) ); await waitForNextUpdate(); result.current.fetchCaseUserActions(basicCase.id); @@ -81,7 +81,7 @@ describe('useGetCaseUserActions', () => { it('set isLoading to true when posting case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCase.id, basicCase.connectorId) + useGetCaseUserActions(basicCase.id, basicCase.connector.id) ); await waitForNextUpdate(); result.current.fetchCaseUserActions(basicCase.id); @@ -98,7 +98,7 @@ describe('useGetCaseUserActions', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useGetCaseUserActions(basicCase.id, basicCase.connectorId) + useGetCaseUserActions(basicCase.id, basicCase.connector.id) ); await waitForNextUpdate(); result.current.fetchCaseUserActions(basicCase.id); @@ -230,7 +230,7 @@ describe('useGetCaseUserActions', () => { const userActions = [ ...caseUserActions, getUserAction(['pushed'], 'push-to-service'), - getUserAction(['connector_id'], 'update'), + getUserAction(['connector'], 'update'), ]; const result = getPushedInfo(userActions, '123'); expect(result).toEqual({ diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx index 76d939de06a0a..afbd1b163cec6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx @@ -6,13 +6,14 @@ import { isEmpty, uniqBy } from 'lodash/fp'; import { useCallback, useEffect, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; +import { CaseFullExternalService } from '../../../../case/common/api/cases'; import { getCaseUserActions } from './api'; import * as i18n from './translations'; import { CaseExternalService, CaseUserActions, ElasticUser } from './types'; import { convertToCamelCase, parseString } from './utils'; -import { CaseFullExternalService } from '../../../../case/common/api/cases'; export interface CaseService extends CaseExternalService { firstPushIndex: number; @@ -49,6 +50,30 @@ export interface UseGetCaseUserActions extends CaseUserActionsState { const getExternalService = (value: string): CaseExternalService | null => convertToCamelCase(parseString(`${value}`)); + +const connectorHasChangedFields = (action: CaseUserActions, connectorId: string): boolean => { + if (action.action !== 'update' || action.actionField[0] !== 'connector') { + return false; + } + + const oldValue = parseString(`${action.oldValue}`); + const newValue = parseString(`${action.newValue}`); + + if (oldValue == null || newValue == null) { + return false; + } + + if (oldValue.id !== connectorId || newValue.id !== connectorId) { + return false; + } + + if (oldValue.id !== newValue.id) { + return false; + } + + return !deepEqual(oldValue.fields, newValue.fields); +}; + interface CommentsAndIndex { commentId: string; commentIndex: number; @@ -62,18 +87,24 @@ export const getPushedInfo = ( hasDataToPush: boolean; } => { const hasDataToPushForConnector = (connectorId: string) => { - const userActionsForPushLessServiceUpdates = caseUserActions.filter( - (mua) => - (mua.action !== 'push-to-service' && - !(mua.action === 'update' && mua.actionField[0] === 'connector_id')) || - (mua.action === 'push-to-service' && - connectorId === getExternalService(`${mua.newValue}`)?.connectorId) - ); + const userActionsForPushLessServiceUpdates = caseUserActions.filter((mua) => { + if (mua.action !== 'push-to-service') { + if (mua.action === 'update' && mua.actionField[0] === 'connector') { + return connectorHasChangedFields(mua, connectorId); + } else { + return true; + } + } else { + return connectorId === getExternalService(`${mua.newValue}`)?.connectorId; + } + }); + return ( userActionsForPushLessServiceUpdates[userActionsForPushLessServiceUpdates.length - 1] .action !== 'push-to-service' ); }; + const commentsAndIndex = caseUserActions.reduce( (bacc, mua, index) => mua.actionField[0] === 'comment' && mua.commentId != null diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx index 8b105fe041d27..c4363236a0977 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx @@ -8,6 +8,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePostCase, UsePostCase } from './use_post_case'; import { basicCasePost } from './mock'; import * as api from './api'; +import { ConnectorTypes } from '../../../../case/common/api/connectors'; jest.mock('./api'); @@ -17,6 +18,12 @@ describe('usePostCase', () => { description: 'description', tags: ['tags'], title: 'title', + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, }; beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx index 0752fe9b2071c..3ca78dfe75c80 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx @@ -49,7 +49,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => }; export interface UsePostCase extends NewCaseState { - postCase: (data: CasePostRequest) => void; + postCase: (data: CasePostRequest) => Promise<() => void>; } export const usePostCase = (): UsePostCase => { const [state, dispatch] = useReducer(dataFetchReducer, { 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 1720396f2a73c..80cd77192a4a0 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 @@ -20,6 +20,7 @@ import { } from './mock'; import * as api from './api'; import { CaseServices } from './use_get_case_user_actions'; +import { CaseConnector, ConnectorTypes } from '../../../../case/common/api/connectors'; jest.mock('./api'); @@ -37,8 +38,12 @@ describe('usePostPushToService', () => { hasDataToPush: false, }, }, - connectorName: 'connector name', - connectorId: '123', + connector: { + id: '123', + name: 'connector name', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + } as CaseConnector, updateCase, }; const sampleServiceRequestData = { @@ -60,7 +65,11 @@ describe('usePostPushToService', () => { title: pushedCase.title, updatedAt: pushedCase.updatedAt, updatedBy: serviceConnectorUser, + issueType: 'Task', + parent: null, + priority: 'Low', }; + const sampleCaseServices = { '123': { ...basicPush, @@ -79,6 +88,7 @@ describe('usePostPushToService', () => { hasDataToPush: false, }, }; + it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -108,8 +118,8 @@ describe('usePostPushToService', () => { expect(spyOnPushCase).toBeCalledWith( samplePush.caseId, { - connector_id: samplePush.connectorId, - connector_name: samplePush.connectorName, + connector_id: samplePush.connector.id, + connector_name: samplePush.connector.name, external_id: serviceConnector.id, external_title: serviceConnector.title, external_url: serviceConnector.url, @@ -130,8 +140,12 @@ describe('usePostPushToService', () => { result.current.postPushToService(samplePush); await waitForNextUpdate(); expect(spyOnPushToService).toBeCalledWith( - samplePush.connectorId, - formatServiceRequestData(basicCase, '123', sampleCaseServices as CaseServices), + samplePush.connector.id, + formatServiceRequestData( + basicCase, + samplePush.connector, + sampleCaseServices as CaseServices + ), abortCtrl.signal ); }); @@ -141,8 +155,12 @@ describe('usePostPushToService', () => { const samplePush2 = { caseId: pushedCase.id, caseServices: {}, - connectorName: 'connector name', - connectorId: 'none', + connector: { + name: 'connector name', + id: 'none', + type: ConnectorTypes.none, + fields: null, + }, updateCase, }; const spyOnPushToService = jest.spyOn(api, 'pushToService'); @@ -155,8 +173,8 @@ describe('usePostPushToService', () => { result.current.postPushToService(samplePush2); await waitForNextUpdate(); expect(spyOnPushToService).toBeCalledWith( - samplePush2.connectorId, - formatServiceRequestData(basicCase, 'none', {}), + samplePush2.connector.id, + formatServiceRequestData(basicCase, samplePush2.connector, {}), abortCtrl.signal ); }); @@ -193,15 +211,22 @@ describe('usePostPushToService', () => { it('formatServiceRequestData - current connector', () => { const caseServices = sampleCaseServices; - const result = formatServiceRequestData(pushedCase, '123', caseServices); + const result = formatServiceRequestData(pushedCase, samplePush.connector, caseServices); expect(result).toEqual(sampleServiceRequestData); }); it('formatServiceRequestData - connector with history', () => { const caseServices = sampleCaseServices; - const result = formatServiceRequestData(pushedCase, '456', caseServices); + const connector = { + id: '456', + name: 'connector 2', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: 'RJ-01' }, + }; + const result = formatServiceRequestData(pushedCase, connector as CaseConnector, caseServices); expect(result).toEqual({ ...sampleServiceRequestData, + ...connector.fields, externalId: 'other_external_id', }); }); @@ -210,9 +235,16 @@ describe('usePostPushToService', () => { const caseServices = { '123': sampleCaseServices['123'], }; - const result = formatServiceRequestData(pushedCase, '456', caseServices); + const connector = { + id: '456', + name: 'connector 2', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + const result = formatServiceRequestData(pushedCase, connector as CaseConnector, caseServices); expect(result).toEqual({ ...sampleServiceRequestData, + ...connector.fields, externalId: null, }); }); 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 346390bd2a49f..b2d865122c759 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 @@ -9,6 +9,7 @@ import { useReducer, useCallback } from 'react'; import { ServiceConnectorCaseResponse, ServiceConnectorCaseParams, + CaseConnector, } from '../../../../case/common/api'; import { errorToToaster, @@ -68,8 +69,7 @@ const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServ interface PushToServiceRequest { caseId: string; - connectorId: string; - connectorName: string; + connector: CaseConnector; caseServices: CaseServices; updateCase: (newCase: Case) => void; } @@ -78,8 +78,7 @@ export interface UsePostPushToService extends PushToServiceState { postPushToService: ({ caseId, caseServices, - connectorId, - connectorName, + connector, updateCase, }: PushToServiceRequest) => void; } @@ -94,28 +93,22 @@ export const usePostPushToService = (): UsePostPushToService => { const [, dispatchToaster] = useStateToaster(); const postPushToService = useCallback( - async ({ - caseId, - caseServices, - connectorId, - connectorName, - updateCase, - }: PushToServiceRequest) => { + async ({ caseId, caseServices, connector, updateCase }: PushToServiceRequest) => { let cancel = false; const abortCtrl = new AbortController(); try { dispatch({ type: 'FETCH_INIT' }); const casePushData = await getCase(caseId, true, abortCtrl.signal); const responseService = await pushToService( - connectorId, - formatServiceRequestData(casePushData, connectorId, caseServices), + connector.id, + formatServiceRequestData(casePushData, connector, caseServices), abortCtrl.signal ); const responseCase = await pushCase( caseId, { - connector_id: connectorId, - connector_name: connectorName, + connector_id: connector.id, + connector_name: connector.name, external_id: responseService.id, external_title: responseService.title, external_url: responseService.url, @@ -127,7 +120,7 @@ export const usePostPushToService = (): UsePostPushToService => { dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); updateCase(responseCase); displaySuccessToast( - i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connectorName), + i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name), dispatchToaster ); } @@ -155,7 +148,7 @@ export const usePostPushToService = (): UsePostPushToService => { export const formatServiceRequestData = ( myCase: Case, - connectorId: string, + connector: CaseConnector, caseServices: CaseServices ): ServiceConnectorCaseParams => { const { @@ -168,7 +161,7 @@ export const formatServiceRequestData = ( updatedAt, updatedBy, } = myCase; - const actualExternalService = caseServices[connectorId] ?? null; + const actualExternalService = caseServices[connector.id] ?? null; return { savedObjectId: caseId, @@ -202,6 +195,7 @@ export const formatServiceRequestData = ( description, externalId: actualExternalService?.externalId ?? null, title, + ...(connector.fields ?? {}), updatedAt, updatedBy: updatedBy != null diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index 6ede91b572dba..c305399ee02d0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -19,7 +19,7 @@ import { Case } from './types'; export type UpdateKey = keyof Pick< CasePatchRequest, - 'connector_id' | 'description' | 'status' | 'tags' | 'title' + 'connector' | 'description' | 'status' | 'tags' | 'title' >; interface NewCaseState { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index 5b595c5892ef2..003439422442b 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -230,3 +230,7 @@ export const CONNECTORS = i18n.translate('xpack.securitySolution.case.caseView.c export const EDIT_CONNECTOR = i18n.translate('xpack.securitySolution.case.caseView.editConnector', { defaultMessage: 'Change external incident management system', }); + +export const NO_CONNECTOR = i18n.translate('xpack.securitySolution.case.common.noConnector', { + defaultMessage: 'No connector selected', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx index 3ef00635844f6..4b7895e3196ca 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx @@ -6,7 +6,7 @@ import { appendSearch } from './helpers'; -export const getCaseUrl = (search: string | null) => `${appendSearch(search ?? undefined)}`; +export const getCaseUrl = (search?: string | null) => `${appendSearch(search ?? undefined)}`; export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) => `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9ad7ab5708a21..3c179eb9b45f2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14878,7 +14878,6 @@ "xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "インシデント管理システム", "xpack.securitySolution.case.configureCases.incidentManagementSystemTitle": "外部のインシデント管理システムに接続", "xpack.securitySolution.case.configureCases.mappingFieldNotMapped": "マップされません", - "xpack.securitySolution.case.configureCases.noConnector": "コネクターを選択していません", "xpack.securitySolution.case.configureCases.updateConnector": "コネクターを更新", "xpack.securitySolution.case.configureCases.updateSelectedConnector": "{ connectorName }を更新", "xpack.securitySolution.case.configureCases.warningMessage": "選択したコネクターが削除されました。別のコネクターを選択するか、新しいコネクターを作成してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f60829da37b38..68e5b3a15a572 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14887,7 +14887,6 @@ "xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "事件管理系统", "xpack.securitySolution.case.configureCases.incidentManagementSystemTitle": "连接到外部事件管理系统", "xpack.securitySolution.case.configureCases.mappingFieldNotMapped": "未映射", - "xpack.securitySolution.case.configureCases.noConnector": "未选择连接器", "xpack.securitySolution.case.configureCases.updateConnector": "更新连接器", "xpack.securitySolution.case.configureCases.updateSelectedConnector": "更新 { connectorName }", "xpack.securitySolution.case.configureCases.warningMessage": "选定的连接器已删除。选择不同的连接器或创建新的连接器。", diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 60b908e2ae228..0684707c73824 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -68,6 +68,9 @@ export default function servicenowTest({ getService }: FtrProviderContext) { description: 'a description', externalId: null, title: 'a title', + severity: '1', + urgency: '1', + impact: '1', updatedAt: '2020-06-17T04:37:45.147Z', updatedBy: { fullName: null, username: 'elastic' }, }, @@ -443,6 +446,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }, }) .expect(200); + expect(proxyHaveBeenCalled).to.equal(true); expect(result).to.eql({ status: 'ok', diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index 48840d90476ba..39762866ac506 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -21,6 +21,7 @@ export default ({ getService }: FtrProviderContext): void => { await deleteComments(es); await deleteCasesUserActions(es); }); + it('should return empty response', async () => { const { body } = await supertest .get(`${CASES_URL}/_find`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts index b48740555d7b3..35e92e67f5350 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts @@ -41,6 +41,7 @@ export default ({ getService }: FtrProviderContext): void => { const data = removeServerGeneratedPropertiesFromCase(body); expect(data).to.eql(postCaseResp(postedCase.id)); }); + it('unhappy path - 404s when case is not there', async () => { await supertest.get(`${CASES_URL}/fake-id`).set('kbn-xsrf', 'true').send().expect(404); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts new file mode 100644 index 0000000000000..36f07ef92b5f1 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { CASES_URL } from '../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('migrations', () => { + before(async () => { + await esArchiver.load('cases'); + }); + + after(async () => { + await esArchiver.unload('cases'); + }); + + it('7.10.0 migrates cases connector', async () => { + const { body } = await supertest + .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).key('connector'); + expect(body).not.key('connector_id'); + expect(body.connector).to.eql({ + id: 'connector-1', + name: 'none', + type: '.none', + fields: null, + }); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index caeaf46cbc953..861a1ce78cf7c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -33,6 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + const { body: patchedCases } = await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -56,6 +57,45 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('should patch a case with new connector', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCases } = await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { issueType: 'Task', priority: null, parent: null }, + }, + }, + ], + }) + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + expect(data).to.eql({ + ...postCaseResp(postedCase.id), + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { issueType: 'Task', priority: null, parent: null }, + }, + updated_by: defaultUser, + }); + }); + it('unhappy path - 404s when case is not there', async () => { await supertest .patch(CASES_URL) @@ -114,6 +154,53 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); }); + it('unhappy path - 400s when bad connector type sent', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + connector: { id: 'none', name: 'none', type: '.not-exists', fields: null }, + }, + ], + }) + .expect(400); + }); + + it('unhappy path - 400s when bad connector sent', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + connector: { + id: 'none', + name: 'none', + type: '.jira', + fields: { unsupported: 'value' }, + }, + }, + ], + }) + .expect(400); + }); + it('unhappy path - 409s when conflict', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts index ab668c2c32725..10393753b9779 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts @@ -35,6 +35,7 @@ export default ({ getService }: FtrProviderContext): void => { const data = removeServerGeneratedPropertiesFromCase(postedCase); expect(data).to.eql(postCaseResp(postedCase.id)); }); + it('unhappy path - 400s when bad query supplied', async () => { await supertest .post(CASES_URL) @@ -42,5 +43,42 @@ export default ({ getService }: FtrProviderContext): void => { .send({ ...postCaseReq, badKey: true }) .expect(400); }); + + it('unhappy path - 400s when connector is not supplied', async () => { + const { connector, ...caseWithoutConnector } = postCaseReq; + + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(caseWithoutConnector) + .expect(400); + }); + + it('unhappy path - 400s when connector has wrong type', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...postCaseReq, + connector: { id: 'wrong', name: 'wrong', type: '.not-exists', fields: null }, + }) + .expect(400); + }); + + it('unhappy path - 400s when connector has wrong fields', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...postCaseReq, + connector: { + id: 'wrong', + name: 'wrong', + type: '.jira', + fields: { unsupported: 'value' }, + }, + }) + .expect(400); + }); }); }; 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 68fc37a73b9cd..0d3d3df5bbd17 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 @@ -47,27 +47,42 @@ export default ({ getService }: FtrProviderContext): void => { const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration(connector.id)) + .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: null, impact: null, severity: null }, + }).connector, + }) .expect(200); const { body } = await supertest .post(`${CASES_URL}/${postedCase.id}/_push`) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector_id, - connector_name: configure.connector_name, + connector_id: configure.connector.id, + connector_name: configure.connector.name, external_id: 'external_id', external_title: 'external_title', external_url: 'external_url', }) .expect(200); - expect(body.connector_id).to.eql(configure.connector_id); + + expect(body.connector.id).to.eql(configure.connector.id); expect(body.external_service.pushed_by).to.eql(defaultUser); }); @@ -89,15 +104,23 @@ export default ({ getService }: FtrProviderContext): void => { 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: null, impact: null, severity: null }, + }).connector, + }) .expect(200); await supertest .post(`${CASES_URL}/${postedCase.id}/_push`) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector_id, - connector_name: configure.connector_name, + connector_id: configure.connector.id, + connector_name: configure.connector.name, external_id: 'external_id', external_title: 'external_title', external_url: 'external_url', @@ -113,8 +136,8 @@ export default ({ getService }: FtrProviderContext): void => { .post(`${CASES_URL}/${postedCase.id}/_push`) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector_id, - connector_name: configure.connector_name, + connector_id: configure.connector.id, + connector_name: configure.connector.name, external_id: 'external_id', external_title: 'external_title', external_url: 'external_url', @@ -136,6 +159,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(404); }); + it('unhappy path - 400s when bad data supplied', async () => { await supertest .post(`${CASES_URL}/fake-id/_push`) @@ -145,17 +169,20 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(400); }); + it('unhappy path = 409s when case is closed', async () => { const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') .send(getConfiguration()) .expect(200); + const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') .send(postCaseReq) .expect(200); + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -169,12 +196,13 @@ export default ({ getService }: FtrProviderContext): void => { ], }) .expect(200); + await supertest .post(`${CASES_URL}/${postedCase.id}/_push`) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector_id, - connector_name: configure.connector_name, + connector_id: configure.connector.id, + connector_name: configure.connector.name, external_id: 'external_id', external_title: 'external_title', external_url: 'external_url', 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 f292f51b377bc..594bd727d910f 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 @@ -85,12 +85,19 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].new_value).to.eql('closed'); }); - it(`on update case connector, user action: 'update' should be called with actionFields: ['connector_id']`, async () => { + it(`on update case connector, user action: 'update' should be called with actionFields: ['connector']`, async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') .send(postCaseReq); - const newConnectorId = '12345'; + + const newConnector = { + id: '123', + name: 'Connector', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + await supertest .patch(CASES_URL) .set('kbn-xsrf', 'true') @@ -99,7 +106,7 @@ export default ({ getService }: FtrProviderContext): void => { { id: postedCase.id, version: postedCase.version, - connector_id: newConnectorId, + connector: newConnector, }, ], }) @@ -111,10 +118,12 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); expect(body.length).to.eql(2); - expect(body[1].action_field).to.eql(['connector_id']); + expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); - expect(body[1].old_value).to.eql('none'); - expect(body[1].new_value).to.eql(newConnectorId); + expect(body[1].old_value).to.eql(`{"id":"none","name":"none","type":".none","fields":null}`); + expect(body[1].new_value).to.eql( + `{"id":"123","name":"Connector","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}` + ); }); it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { @@ -280,20 +289,35 @@ export default ({ getService }: FtrProviderContext): void => { const { body: configure } = await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration(connector.id)) + .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: null, impact: null, severity: null }, + }).connector, + }) + .expect(200); await supertest .post(`${CASES_URL}/${postedCase.id}/_push`) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector_id, - connector_name: configure.connector_name, + connector_id: configure.connector.id, + connector_name: configure.connector.name, external_id: 'external_id', external_title: 'external_title', external_url: 'external_url', @@ -311,7 +335,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action).to.eql('push-to-service'); expect(body[1].old_value).to.eql(null); const newValue = JSON.parse(body[1].new_value); - expect(newValue.connector_id).to.eql(configure.connector_id); + expect(newValue.connector_id).to.eql(configure.connector.id); expect(newValue.pushed_by).to.eql(defaultUser); }); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts new file mode 100644 index 0000000000000..198125bdcbc13 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('migrations', () => { + before(async () => { + await esArchiver.load('cases'); + }); + + after(async () => { + await esArchiver.unload('cases'); + }); + + it('7.10.0 migrates user actions connector', async () => { + const { body } = await supertest + .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const connectorUserAction = body[1]; + const oldValue = JSON.parse(connectorUserAction.old_value); + const newValue = JSON.parse(connectorUserAction.new_value); + + expect(connectorUserAction.action_field.length).eql(1); + expect(connectorUserAction.action_field[0]).eql('connector'); + expect(oldValue).to.eql({ + id: 'c1900ac0-017f-11eb-93f8-d161651bf509', + name: 'none', + type: '.none', + fields: null, + }); + expect(newValue).to.eql({ + id: 'b1900ac0-017f-11eb-93f8-d161651bf509', + name: 'none', + type: '.none', + fields: null, + }); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/configure/migrations.ts b/x-pack/test/case_api_integration/basic/tests/configure/migrations.ts new file mode 100644 index 0000000000000..f37d29899ff70 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/configure/migrations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { CASE_CONFIGURE_URL } from '../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('migrations', () => { + before(async () => { + await esArchiver.load('cases'); + }); + + after(async () => { + await esArchiver.unload('cases'); + }); + + it('7.10.0 migrates configure cases connector', async () => { + const { body } = await supertest + .get(`${CASE_CONFIGURE_URL}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).key('connector'); + expect(body).not.key('connector_id'); + expect(body.connector).to.eql({ + id: 'connector-1', + name: 'Connector 1', + type: '.none', + fields: null, + }); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts b/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts index 5f7d7c4c5c346..0192ba5e84a4a 100644 --- a/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts @@ -20,7 +20,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - describe('post_configure', () => { + describe('patch_configure', () => { afterEach(async () => { await deleteConfiguration(es); }); @@ -42,6 +42,36 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql({ ...getConfigurationOutput(true), closure_type: 'close-by-pushing' }); }); + it('should not patch a configuration with unsupported connector type', async () => { + await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send(getConfiguration()) + .expect(200); + + await supertest + .patch(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + // @ts-ignore We need it to test unsupported types + .send(getConfiguration({ type: '.unsupported' })) + .expect(400); + }); + + it('should not patch a configuration with unsupported connector fields', async () => { + await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send(getConfiguration()) + .expect(200); + + await supertest + .patch(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + // @ts-ignore We need it to test unsupported fields + .send(getConfiguration({ type: '.jira', fields: { unsupported: 'value' } })) + .expect(400); + }); + it('should handle patch request when there is no configuration', async () => { const { body } = await supertest .patch(CASE_CONFIGURE_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts b/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts index 86214a2bd3187..723f15e9b2c79 100644 --- a/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts @@ -40,7 +40,7 @@ export default ({ getService }: FtrProviderContext): void => { await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration('connector-2')) + .send(getConfiguration({ id: 'connector-2' })) .expect(200); await supertest @@ -58,5 +58,23 @@ export default ({ getService }: FtrProviderContext): void => { const data = removeServerGeneratedPropertiesFromConfigure(body); expect(data).to.eql(getConfigurationOutput()); }); + + it('should not create a configuration with unsupported connector type', async () => { + await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + // @ts-ignore We need it to test unsupported types + .send(getConfiguration({ type: '.unsupported' })) + .expect(400); + }); + + it('should not create a configuration with unsupported connector fields', async () => { + await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + // @ts-ignore We need it to test unsupported types + .send(getConfiguration({ type: '.jira', fields: { unsupported: 'value' } })) + .expect(400); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index e182c27d72fd2..aaf2338cde2f0 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -31,5 +31,10 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/get_connectors')); loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); + + // Migrations + loadTestFile(require.resolve('./cases/migrations')); + loadTestFile(require.resolve('./configure/migrations')); + loadTestFile(require.resolve('./cases/user_actions/migrations')); }); }; diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index cfa4a0ae977f4..18c57ad3b0b69 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -8,12 +8,19 @@ import { CasePostRequest, CaseResponse, CasesFindResponse, + ConnectorTypes, } from '../../../../plugins/case/common/api'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: '.none' as ConnectorTypes, + fields: null, + }, }; export const postCommentReq: { comment: string } = { @@ -25,7 +32,6 @@ export const postCaseResp = (id: string): Partial => ({ id, comments: [], totalComment: 0, - connector_id: 'none', closed_by: null, created_by: defaultUser, external_service: null, 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 41f92d022f06c..8d28f647ce43b 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -5,13 +5,26 @@ */ import { Client } from '@elastic/elasticsearch'; -import { CasesConfigureRequest, CasesConfigureResponse } from '../../../../plugins/case/common/api'; +import { + CasesConfigureRequest, + CasesConfigureResponse, + CaseConnector, + ConnectorTypes, +} from '../../../../plugins/case/common/api'; -// eslint-disable-next-line @typescript-eslint/naming-convention -export const getConfiguration = (connector_id: string = 'connector-1'): CasesConfigureRequest => { +export const getConfiguration = ({ + id = 'connector-1', + name = 'Connector 1', + type = '.none' as ConnectorTypes, + fields = null, +}: Partial = {}): CasesConfigureRequest => { return { - connector_id, - connector_name: 'Connector 1', + connector: { + id, + name, + type, + fields, + } as CaseConnector, closure_type: 'close-by-user', }; }; diff --git a/x-pack/test/functional/es_archives/cases/data.json b/x-pack/test/functional/es_archives/cases/data.json new file mode 100644 index 0000000000000..2ca805259e318 --- /dev/null +++ b/x-pack/test/functional/es_archives/cases/data.json @@ -0,0 +1,139 @@ +{ + "type": "doc", + "value": { + "id": "cases-configure:e1487a70-017f-11eb-93f8-d161651bf509", + "index": ".kibana_1", + "source": { + "cases-configure": { + "closure_type": "close-by-user", + "connector_id": "connector-1", + "connector_name": "Connector 1", + "created_at": "2020-09-28T11:43:51.698Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "updated_at": null, + "updated_by": null + }, + "migrationVersion": { + "alert": "7.10.0" + }, + "references": [ + ], + "type": "cases-configure", + "updated_at": "2020-09-28T11:43:51.702Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "cases:e1900ac0-017f-11eb-93f8-d161651bf509", + "index": ".kibana_1", + "source": { + "cases": { + "closed_at": null, + "closed_by": null, + "connector_id": "connector-1", + "created_at": "2020-09-28T11:43:52.158Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "status": "open", + "tags": [ + "defacement" + ], + "title": "Super Bad Security Issue", + "updated_at": null, + "updated_by": null + }, + "migrationVersion": { + "alert": "7.10.0" + }, + "references": [ + ], + "type": "cases", + "updated_at": "2020-09-28T11:43:52.171Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "cases-user-actions:e22a7600-017f-11eb-93f8-d161651bf509", + "index": ".kibana_1", + "source": { + "cases-user-actions": { + "action": "create", + "action_at": "2020-09-28T11:43:52.158Z", + "action_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "action_field": [ + "description", + "status", + "tags", + "title" + ], + "new_value": "{\"description\":\"This is a brand new case of a bad meanie defacing data\",\"title\":\"Super Bad Security Issue\",\"tags\":[\"defacement\"]}", + "old_value": null + }, + "references": [ + { + "id": "e1900ac0-017f-11eb-93f8-d161651bf509", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-user-actions", + "updated_at": "2020-09-28T11:43:53.184Z" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "cases-user-actions:a22a7600-017f-11eb-93f8-d161651bf509", + "index": ".kibana_1", + "source": { + "cases-user-actions": { + "action": "update", + "action_at": "2020-09-28T11:53:52.158Z", + "action_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "action_field": [ + "connector_id" + ], + "new_value": "b1900ac0-017f-11eb-93f8-d161651bf509", + "old_value": "c1900ac0-017f-11eb-93f8-d161651bf509" + }, + "references": [ + { + "id": "e1900ac0-017f-11eb-93f8-d161651bf509", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-user-actions", + "updated_at": "2020-09-28T11:43:53.184Z" + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/cases/mappings.json b/x-pack/test/functional/es_archives/cases/mappings.json new file mode 100644 index 0000000000000..ee128369ddd2b --- /dev/null +++ b/x-pack/test/functional/es_archives/cases/mappings.json @@ -0,0 +1,2556 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "7b44fba6773e37c806ce290ea9b7024e", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "app_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", + "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "canvas-workpad-template": "ae2673f678281e2c055d764b153e9715", + "cases": "32aa96a6d3855ddda53010ae2048ac22", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "c63748b75f39d0c54de12d12c1ccbc20", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", + "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "epm-packages": "8f6e0b09ea0374c4ffe98c3755373cff", + "exception-list": "497afa2f881a675d72d58e20057f3d8b", + "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "fleet-agent-actions": "e520c855577170c24481be05c3ae14ec", + "fleet-agent-events": "3231653fafe4ef3196fe3b32ab774bf2", + "fleet-agents": "034346488514b7058a79140b19ddf631", + "fleet-enrollment-api-keys": "28b91e20b105b6f928e2012600085d8f", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "2b2809653635caf490c93f090502d04c", + "ingest-agent-policies": "9326f99c977fd2ef5ab24b6336a0675c", + "ingest-outputs": "8aa988c376e65443fefc26f1075e93a3", + "ingest-package-policies": "8545e51d7bc8286d6dace3d41240d749", + "ingest_manager_settings": "012cf278ec84579495110bb827d1ed09", + "inventory-view": "88fc7e12fd1b45b6f0787323ce4f18d2", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "d33c68a69ff1e78c9888dedd2164ac22", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "4a05b35c3a3a58fbc72dd0202dc3487f", + "maps-telemetry": "5ef305b18111b77789afefbd36b66171", + "metrics-explorer-view": "a8df1d270ee48c969d22d23812d08187", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "5c4b9a6effceb17ae8a0ab22d0c49767", + "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "94bc38c7a421d15fbfe8ea565370a421", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "215107c281839ea9b3ad5f6419819763", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "fcdb453a30092f022f2642db29523d80", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "visualization": "52d7a13ad68a150c4525b292d23e12cc", + "workplace_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "params": { + "enabled": false, + "type": "object" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "dynamic": "false", + "type": "object" + }, + "app_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "application_usage_daily": { + "dynamic": "false", + "properties": { + "timestamp": { + "type": "date" + } + } + }, + "application_usage_totals": { + "dynamic": "false", + "type": "object" + }, + "application_usage_transactional": { + "dynamic": "false", + "type": "object" + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad-template": { + "dynamic": "false", + "properties": { + "help": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "tags": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "template_key": { + "type": "keyword" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "false", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "endpoint:user-artifact": { + "properties": { + "body": { + "type": "binary" + }, + "compressionAlgorithm": { + "index": false, + "type": "keyword" + }, + "created": { + "index": false, + "type": "date" + }, + "decodedSha256": { + "index": false, + "type": "keyword" + }, + "decodedSize": { + "index": false, + "type": "long" + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "index": false, + "type": "long" + }, + "encryptionAlgorithm": { + "index": false, + "type": "keyword" + }, + "identifier": { + "type": "keyword" + } + } + }, + "endpoint:user-artifact-manifest": { + "properties": { + "created": { + "index": false, + "type": "date" + }, + "ids": { + "index": false, + "type": "keyword" + }, + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "index": false, + "type": "keyword" + } + } + }, + "epm-packages": { + "properties": { + "es_index_patterns": { + "enabled": false, + "type": "object" + }, + "installed_es": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "installed_kibana": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "removable": { + "type": "boolean" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list-agnostic": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "fleet-agent-actions": { + "properties": { + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "binary" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agent-events": { + "properties": { + "action_id": { + "type": "keyword" + }, + "agent_id": { + "type": "keyword" + }, + "config_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "stream_id": { + "type": "keyword" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "config_id": { + "type": "keyword" + }, + "config_revision": { + "type": "integer" + }, + "current_error_events": { + "index": false, + "type": "text" + }, + "default_api_key": { + "type": "binary" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "flattened" + }, + "packages": { + "type": "keyword" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "flattened" + }, + "version": { + "type": "keyword" + } + } + }, + "fleet-enrollment-api-keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "config_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "inventoryDefaultView": { + "type": "keyword" + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + }, + "type": "nested" + }, + "metricAlias": { + "type": "keyword" + }, + "metricsExplorerDefaultView": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "ingest-agent-policies": { + "properties": { + "description": { + "type": "text" + }, + "is_default": { + "type": "boolean" + }, + "monitoring_enabled": { + "index": false, + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "package_configs": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest-outputs": { + "properties": { + "ca_sha256": { + "index": false, + "type": "keyword" + }, + "config": { + "type": "flattened" + }, + "fleet_enroll_password": { + "type": "binary" + }, + "fleet_enroll_username": { + "type": "binary" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "ingest-package-policies": { + "properties": { + "config_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "enabled": false, + "properties": { + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "streams": { + "properties": { + "compiled_stream": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest_manager_settings": { + "properties": { + "agent_auto_upgrade": { + "type": "keyword" + }, + "has_seen_add_data_notice": { + "index": false, + "type": "boolean" + }, + "kibana_ca_sha256": { + "type": "keyword" + }, + "kibana_url": { + "type": "keyword" + }, + "package_auto_upgrade": { + "type": "keyword" + } + } + }, + "inventory-view": { + "properties": { + "accountId": { + "type": "keyword" + }, + "autoBounds": { + "type": "boolean" + }, + "autoReload": { + "type": "boolean" + }, + "boundsOverride": { + "properties": { + "max": { + "type": "integer" + }, + "min": { + "type": "integer" + } + } + }, + "customMetrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "customOptions": { + "properties": { + "field": { + "type": "keyword" + }, + "text": { + "type": "keyword" + } + }, + "type": "nested" + }, + "filterQuery": { + "properties": { + "expression": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + } + } + }, + "groupBy": { + "properties": { + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + }, + "legend": { + "properties": { + "palette": { + "type": "keyword" + }, + "reverseColors": { + "type": "boolean" + }, + "steps": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "nodeType": { + "type": "keyword" + }, + "region": { + "type": "keyword" + }, + "sort": { + "properties": { + "by": { + "type": "keyword" + }, + "direction": { + "type": "keyword" + } + } + }, + "time": { + "type": "long" + }, + "view": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "enabled": false, + "type": "object" + }, + "metrics-explorer-view": { + "properties": { + "chartOptions": { + "properties": { + "stack": { + "type": "boolean" + }, + "type": { + "type": "keyword" + }, + "yAxisMode": { + "type": "keyword" + } + } + }, + "currentTimerange": { + "properties": { + "from": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "to": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "options": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "filterQuery": { + "type": "keyword" + }, + "forceInterval": { + "type": "boolean" + }, + "groupBy": { + "type": "keyword" + }, + "limit": { + "type": "integer" + }, + "metrics": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + }, + "type": "nested" + }, + "source": { + "type": "keyword" + } + } + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "index": false, + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "sort": { + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "search-telemetry": { + "dynamic": "false", + "type": "object" + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eventType": { + "type": "keyword" + }, + "excludedRowRendererIds": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "templateTimelineId": { + "type": "text" + }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "long" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "properties": { + "certAgeThreshold": { + "type": "long" + }, + "certExpirationThreshold": { + "type": "long" + }, + "heartbeatIndices": { + "type": "keyword" + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "workplace_search_telemetry": { + "dynamic": "false", + "type": "object" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file