diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 0e728a4dada24..1ef00aa9de115 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -362,7 +362,8 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases] -|Case management in Kibana +|[![Issues][issues-shield]][issues-url] +[![Pull Requests][pr-shield]][pr-url] |{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud] diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 92adbaf97d8c5..93d0ee3d2cab6 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -35,10 +35,14 @@ a| <> | Add a message to a Kibana log. -a| <> +a| <> | Create an incident in ServiceNow. +a| <> + +| Create a security incident in ServiceNow. + a| <> | Send a message to a Slack channel or user. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc new file mode 100644 index 0000000000000..4556746284d5b --- /dev/null +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -0,0 +1,89 @@ +[role="xpack"] +[[servicenow-sir-action-type]] +=== ServiceNow connector and action +++++ +ServiceNow SecOps +++++ + +The ServiceNow SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow security incidents. + +[float] +[[servicenow-sir-connector-configuration]] +==== Connector configuration + +ServiceNow SecOps connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +The ServiceNow user requires at minimum read, create, and update access to the Security Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + +[float] +[[servicenow-sir-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-servicenow-sir-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-servicenow-sir: + name: preconfigured-servicenow-connector-type + actionTypeId: .servicenow-sir + config: + apiUrl: https://dev94428.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`username`:: A string that corresponds to *Username*. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. + +[float] +[[define-servicenow-sir-ui]] +==== Define connector in Stack Management + +Define ServiceNow SecOps connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector] + +Test ServiceNow SecOps action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow SecOps params test] + +[float] +[[servicenow-sir-action-configuration]] +==== Action configuration + +ServiceNow SecOps actions have the following configuration properties. + +Short description:: A short description for the incident, used for searching the contents of the knowledge base. +Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident. +Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident. +Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident. +Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident. +Priority:: The priority of the incident. +Category:: The category of the incident. +Subcategory:: The subcategory of the incident. +Description:: The details about the incident. +Additional comments:: Additional information for the client, such as how to troubleshoot the issue. + +[float] +[[configuring-servicenow-sir]] +==== Configure ServiceNow SecOps + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 3a4134cbf982e..cf5244a9e3f9e 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -2,16 +2,16 @@ [[servicenow-action-type]] === ServiceNow connector and action ++++ -ServiceNow +ServiceNow ITSM ++++ -The ServiceNow connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. +The ServiceNow ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. [float] [[servicenow-connector-configuration]] ==== Connector configuration -ServiceNow connectors have the following configuration properties. +ServiceNow ITSM connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. URL:: ServiceNow instance URL. @@ -55,12 +55,12 @@ Secrets defines sensitive information for the connector type. [[define-servicenow-ui]] ==== Define connector in Stack Management -Define ServiceNow connector properties. +Define ServiceNow ITSM connector properties. [role="screenshot"] image::management/connectors/images/servicenow-connector.png[ServiceNow connector] -Test ServiceNow action parameters. +Test ServiceNow ITSM action parameters. [role="screenshot"] image::management/connectors/images/servicenow-params-test.png[ServiceNow params test] @@ -69,11 +69,13 @@ image::management/connectors/images/servicenow-params-test.png[ServiceNow params [[servicenow-action-configuration]] ==== Action configuration -ServiceNow actions have the following configuration properties. +ServiceNow ITSM actions have the following configuration properties. Urgency:: The extent to which the incident resolution can delay. Severity:: The severity of the incident. Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. +Category:: The category of the incident. +Subcategory:: The category of the incident. Short description:: A short description for the incident, used for searching the contents of the knowledge base. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png index 16ea83c60b3c3..80103a4272bfa 100644 Binary files a/docs/management/connectors/images/servicenow-sir-params-test.png and b/docs/management/connectors/images/servicenow-sir-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 033b1c3ac150e..536d05705181d 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/servicenow-sir.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index b19e89a599840..0d66c9d30f8b9 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -33,29 +33,36 @@ Table of Contents - [actionsClient.execute(options)](#actionsclientexecuteoptions) - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - - [ServiceNow](#servicenow) + - [ServiceNow ITSM](#servicenow-itsm) - [`params`](#params) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice) - [`subActionParams (getFields)`](#subactionparams-getfields) - [`subActionParams (getIncident)`](#subactionparams-getincident) - [`subActionParams (getChoices)`](#subactionparams-getchoices) - - [Jira](#jira) + - [ServiceNow Sec Ops](#servicenow-sec-ops) - [`params`](#params-1) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [`subActionParams (getFields)`](#subactionparams-getfields-1) - [`subActionParams (getIncident)`](#subactionparams-getincident-1) + - [`subActionParams (getChoices)`](#subactionparams-getchoices-1) + - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-) + - [Jira](#jira) + - [`params`](#params-2) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) + - [`subActionParams (getIncident)`](#subactionparams-getincident-2) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) - [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype) - [`subActionParams (issues)`](#subactionparams-issues) - [`subActionParams (issue)`](#subactionparams-issue) - - [`subActionParams (getFields)`](#subactionparams-getfields-1) - - [IBM Resilient](#ibm-resilient) - - [`params`](#params-2) - - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [`subActionParams (getFields)`](#subactionparams-getfields-2) + - [IBM Resilient](#ibm-resilient) + - [`params`](#params-3) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) + - [`subActionParams (getFields)`](#subactionparams-getfields-3) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) - [Swimlane](#swimlane) - - [`params`](#params-3) + - [`params`](#params-4) - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ In addition to the documented configurations, several built in action type offer additional `params` configurations. -## ServiceNow +## ServiceNow ITSM -The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. +The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. ### `params` | Property | Description | Type | @@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | -| short_description | The title of the incident. | string | -| description | The description of the incident. | string _(optional)_ | -| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| severity | The severity in ServiceNow. | string _(optional)_ | -| urgency | The urgency in ServiceNow. | string _(optional)_ | -| impact | The impact in ServiceNow. | string _(optional)_ | -| category | The category in ServiceNow. | string _(optional)_ | -| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| Property | Description | Type | +| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | +| short_description | The title of the incident. | string | +| description | The description of the incident. | string _(optional)_ | +| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | +| severity | The severity in ServiceNow. | string _(optional)_ | +| urgency | The urgency in ServiceNow. | string _(optional)_ | +| impact | The impact in ServiceNow. | string _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the incident. | string _(optional)_ | +| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` @@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`. #### `subActionParams (getChoices)` -| Property | Description | Type | -| -------- | ------------------------------------------------------------ | -------- | -| fields | An array of fields. Example: `[priority, category, impact]`. | string[] | +| Property | Description | Type | +| -------- | -------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[category, impact]`. | string[] | --- +## ServiceNow Sec Ops + +The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. + +### `params` + +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string | +| subActionParams | The parameters of the subaction. | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- | +| incident | The ServiceNow security incident. | object | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | + +The following table describes the properties of the `incident` object. + +| Property | Description | Type | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| short_description | The title of the security incident. | string | +| description | The description of the security incident. | string _(optional)_ | +| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ | +| priority | The priority in ServiceNow. | string _(optional)_ | +| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the security incident. | string _(optional)_ | +| correlation_display | The correlation display of the security incident. | string _(optional)_ | + +#### `subActionParams (getFields)` + +No parameters for the `getFields` subaction. Provide an empty object `{}`. + +#### `subActionParams (getIncident)` + +| Property | Description | Type | +| ---------- | ---------------------------------------------- | ------ | +| externalId | The ID of the security incident in ServiceNow. | string | + + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ---------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[priority, category]`. | string[] | +--- ## Jira The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index 5d83b658111e4..7710ff79d08b4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -143,7 +143,7 @@ export function getActionType({ }), validate: { config: schema.object(configSchemaProps, { - validate: curry(valdiateActionTypeConfig)(configurationUtilities), + validate: curry(validateActionTypeConfig)(configurationUtilities), }), secrets: SecretsSchema, params: ParamsSchema, @@ -152,7 +152,7 @@ export function getActionType({ }; } -function valdiateActionTypeConfig( +function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType ) { 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 8d24e48d4d515..e1f66263729e2 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 @@ -25,6 +25,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -57,6 +58,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -78,6 +80,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, commentFieldKey: 'comments', @@ -93,6 +96,9 @@ describe('api', () => { caller_id: 'elastic', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', + opened_by: 'elastic', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -103,6 +109,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -118,6 +125,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -132,6 +141,8 @@ describe('api', () => { comments: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -142,6 +153,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -157,6 +169,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -171,6 +185,8 @@ describe('api', () => { work_notes: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -182,6 +198,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params: apiParams, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -210,6 +227,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -228,6 +246,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -243,6 +262,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -253,6 +274,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -267,6 +289,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -281,6 +305,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -291,6 +317,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -305,6 +332,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -319,6 +348,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -344,4 +375,23 @@ describe('api', () => { expect(res).toEqual(serviceNowChoices); }); }); + + describe('getIncident', () => { + test('it gets the incident correctly', async () => { + const res = await api.getIncident({ + externalService, + params: { + externalId: 'incident-1', + }, + }); + expect(res).toEqual({ + description: 'description from servicenow', + id: 'incident-1', + pushedDate: '2020-03-10T12:24:20.000Z', + short_description: 'title from servicenow', + title: 'INC01', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + }); }); 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 4120c07c32303..88cdfd069cf1b 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 @@ -6,7 +6,7 @@ */ import { - ExternalServiceApi, + ExternalServiceAPI, GetChoicesHandlerArgs, GetChoicesResponse, GetCommonFieldsHandlerArgs, @@ -19,7 +19,11 @@ import { } from './types'; const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; -const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => { + const { externalId: id } = params; + const res = await externalService.getIncident(id); + return res; +}; const pushToServiceHandler = async ({ externalService, @@ -42,6 +46,7 @@ const pushToServiceHandler = async ({ incident: { ...incident, caller_id: secrets.username, + opened_by: secrets.username, }, }); } @@ -84,7 +89,7 @@ const getChoicesHandler = async ({ return res; }; -export const api: ExternalServiceApi = { +export const api: ExternalServiceAPI = { getChoices: getChoicesHandler, getFields: getFieldsHandler, getIncident: getIncidentHandler, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts new file mode 100644 index 0000000000000..358af7cd2e9ef --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -0,0 +1,286 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '../../../../../../src/core/server'; +import { externalServiceSIRMock, sirParams } from './mocks'; +import { ExternalServiceSIR, ObservableTypes } from './types'; +import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir'; +let mockedLogger: jest.Mocked; + +describe('api_sir', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceSIRMock.create(); + jest.clearAllMocks(); + }); + + describe('combineObservables', () => { + test('it returns an empty array when both arguments are an empty array', async () => { + expect(combineObservables([], [])).toEqual([]); + }); + + test('it returns an empty array when both arguments are an empty string', async () => { + expect(combineObservables('', '')).toEqual([]); + }); + + test('it returns an empty array when a="" and b=[]', async () => { + expect(combineObservables('', [])).toEqual([]); + }); + + test('it returns an empty array when a=[] and b=""', async () => { + expect(combineObservables([], '')).toEqual([]); + }); + + test('it returns a if b is empty', async () => { + expect(combineObservables('a', '')).toEqual(['a']); + }); + + test('it returns b if a is empty', async () => { + expect(combineObservables([], ['b'])).toEqual(['b']); + }); + + test('it combines two strings', async () => { + expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']); + }); + + test('it combines two arrays', async () => { + expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']); + }); + + test('it combines a string with an array', async () => { + expect(combineObservables('a', ['b'])).toEqual(['a', 'b']); + }); + + test('it combines an array with a string ', async () => { + expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']); + }); + + test('it combines a "," concatenated string', async () => { + expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "|" concatenated string', async () => { + expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a space concatenated string', async () => { + expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\n" concatenated string', async () => { + expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\r" concatenated string', async () => { + expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\t" concatenated string', async () => { + expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines two strings with different delimiter', async () => { + expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); + }); + + describe('formatObservables', () => { + test('it formats array observables correctly', async () => { + const expectedTypes: Array<[ObservableTypes, string]> = [ + [ObservableTypes.ip4, 'ipv4-addr'], + [ObservableTypes.sha256, 'SHA256'], + [ObservableTypes.url, 'URL'], + ]; + + for (const type of expectedTypes) { + expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([ + { type: type[1], value: 'a' }, + { type: type[1], value: 'b' }, + { type: type[1], value: 'c' }, + ]); + } + }); + + test('it removes duplicates from array observables correctly', async () => { + expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it formats an empty array correctly', async () => { + expect(formatObservables([], ObservableTypes.ip4)).toEqual([]); + }); + + test('it removes empty observables correctly', async () => { + expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + }); + + describe('prepareParams', () => { + test('it prepares the params correctly when the connector is legacy', async () => { + expect(prepareParams(true, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: '192.168.1.1,192.168.1.3', + source_ip: '192.168.1.2,192.168.1.4', + malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + malware_url: 'https://example.com', + }, + }); + }); + + test('it prepares the params correctly when the connector is not legacy', async () => { + expect(prepareParams(false, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + + test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => { + const { + dest_ip: destIp, + source_ip: sourceIp, + malware_hash: malwareHash, + malware_url: malwareURL, + ...incidentWithoutObservables + } = sirParams.incident; + + expect( + prepareParams(true, { + ...sirParams, + // @ts-expect-error + incident: incidentWithoutObservables, + }) + ).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + }); + + describe('pushToService', () => { + test('it creates an incident correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + const res = await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('it adds observables correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith( + [ + { type: 'ipv4-addr', value: '192.168.1.1' }, + { type: 'ipv4-addr', value: '192.168.1.3' }, + { type: 'ipv4-addr', value: '192.168.1.2' }, + { type: 'ipv4-addr', value: '192.168.1.4' }, + { + type: 'SHA256', + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + }, + { type: 'URL', value: 'https://example.com' }, + ], + // createIncident mock returns this incident id + 'incident-1' + ); + }); + + test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: true }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + + test('it does not call bulkAddObservableToIncident if there are no observables', async () => { + const params = { + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + externalId: null, + }, + }; + + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts new file mode 100644 index 0000000000000..326bb79a0e708 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty, isString } from 'lodash'; + +import { + ExecutorSubActionPushParamsSIR, + ExternalServiceAPI, + ExternalServiceSIR, + ObservableTypes, + PushToServiceApiHandlerArgs, + PushToServiceApiParamsSIR, + PushToServiceResponse, +} from './types'; + +import { api } from './api'; + +const SPLIT_REGEX = /[ ,|\r\n\t]+/; + +export const formatObservables = (observables: string[], type: ObservableTypes) => { + /** + * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. + * Before the application the observables were being sent to ServiceNow as a concatenated string with + * delimiter. With the application the format changed to an array of observables. + */ + const uniqueObservables = new Set(observables); + return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type })); +}; + +const obsAsArray = (obs: string | string[]): string[] => { + if (isEmpty(obs)) { + return []; + } + + if (isString(obs)) { + return obs.split(SPLIT_REGEX); + } + + return obs; +}; + +export const combineObservables = (a: string | string[], b: string | string[]): string[] => { + const first = obsAsArray(a); + const second = obsAsArray(b); + + return [...first, ...second]; +}; + +const observablesToString = (obs: string | string[] | null | undefined): string | null => { + if (Array.isArray(obs)) { + return obs.join(','); + } + + return obs ?? null; +}; + +export const prepareParams = ( + isLegacy: boolean, + params: PushToServiceApiParamsSIR +): PushToServiceApiParamsSIR => { + if (isLegacy) { + /** + * The schema has change to accept an array of observables + * or a string. In the case of a legacy connector we need to + * convert the observables to a string + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: observablesToString(params.incident.dest_ip), + malware_hash: observablesToString(params.incident.malware_hash), + malware_url: observablesToString(params.incident.malware_url), + source_ip: observablesToString(params.incident.source_ip), + }, + }; + } + + /** + * For non legacy connectors the observables + * will be added in a different call. + * They need to be set to null when sending the fields + * to ServiceNow + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: null, + malware_hash: null, + malware_url: null, + source_ip: null, + }, + }; +}; + +const pushToServiceHandler = async ({ + externalService, + params, + config, + secrets, + commentFieldKey, + logger, +}: PushToServiceApiHandlerArgs): Promise => { + const res = await api.pushToService({ + externalService, + params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR), + config, + secrets, + commentFieldKey, + logger, + }); + + const { + incident: { + dest_ip: destIP, + malware_hash: malwareHash, + malware_url: malwareUrl, + source_ip: sourceIP, + }, + } = params as ExecutorSubActionPushParamsSIR; + + /** + * Add bulk observables is only available for new connectors + * Old connectors gonna add their observables + * through the pushToService call. + */ + + if (!config.isLegacy) { + const sirExternalService = externalService as ExternalServiceSIR; + + const obsWithType: Array<[string[], ObservableTypes]> = [ + [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], + [obsAsArray(malwareHash ?? []), ObservableTypes.sha256], + [obsAsArray(malwareUrl ?? []), ObservableTypes.url], + ]; + + const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); + if (observables.length > 0) { + await sirExternalService.bulkAddObservableToIncident(observables, res.id); + } + } + + return res; +}; + +export const apiSIR: ExternalServiceAPI = { + ...api, + pushToService: pushToServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts new file mode 100644 index 0000000000000..babd360cbcb82 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { snExternalServiceConfig } from './config'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('config', () => { + test('ITSM: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); + + test('SIR: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow-sir']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..37e4c6994b403 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, +} from '../../constants/connectors'; +import { SNProductsConfig } from './types'; + +export const serviceNowITSMTable = 'incident'; +export const serviceNowSIRTable = 'sn_si_incident'; + +export const ServiceNowITSMActionTypeId = '.servicenow'; +export const ServiceNowSIRActionTypeId = '.servicenow-sir'; + +export const snExternalServiceConfig: SNProductsConfig = { + '.servicenow': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR, + commentFieldKey: 'work_notes', + }, + '.servicenow-sir': { + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, + commentFieldKey: 'work_notes', + }, +}; + +export const FIELD_PREFIX = 'u_'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index f2b500df6ccb3..29907381d45da 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -18,7 +18,7 @@ import { import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; import { createExternalService } from './service'; -import { api } from './api'; +import { api as commonAPI } from './api'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; import { @@ -30,7 +30,25 @@ import { ExecutorSubActionCommonFieldsParams, ServiceNowExecutorResultData, ExecutorSubActionGetChoicesParams, + ServiceFactory, + ExternalServiceAPI, } from './types'; +import { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, + snExternalServiceConfig, +} from './config'; +import { createExternalServiceSIR } from './service_sir'; +import { apiSIR } from './api_sir'; + +export { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, +}; export type ActionParamsType = | TypeOf @@ -41,12 +59,6 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -const serviceNowITSMTable = 'incident'; -const serviceNowSIRTable = 'sn_si_incident'; - -export const ServiceNowITSMActionTypeId = '.servicenow'; -export const ServiceNowSIRActionTypeId = '.servicenow-sir'; - export type ServiceNowActionType = ActionType< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -79,8 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowITSMTable, - commentFieldKey: 'work_notes', + actionTypeId: ServiceNowITSMActionTypeId, + createService: createExternalService, + api: commonAPI, }), }; } @@ -103,8 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowSIRTable, - commentFieldKey: 'work_notes', + actionTypeId: ServiceNowSIRActionTypeId, + createService: createExternalServiceSIR, + api: apiSIR, }), }; } @@ -115,28 +129,31 @@ async function executor( { logger, configurationUtilities, - table, - commentFieldKey = 'comments', + actionTypeId, + createService, + api, }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; - table: string; - commentFieldKey?: string; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceAPI; }, execOptions: ServiceNowActionTypeExecutorOptions ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; - const externalService = createExternalService( - table, + const externalService = createService( { config, secrets, }, logger, - configurationUtilities + configurationUtilities, + externalServiceConfig ); if (!api[subAction]) { @@ -156,9 +173,10 @@ async function executor( data = await api.pushToService({ externalService, params: pushToServiceParams, + config, secrets, logger, - commentFieldKey, + commentFieldKey: externalServiceConfig.commentFieldKey, }); logger.debug(`response push to service for incident id: ${data.id}`); 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 909200472be33..3629fb33915ae 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 @@ -5,7 +5,14 @@ * 2.0. */ -import { ExternalService, ExecutorSubActionPushParams } from './types'; +import { + ExternalService, + ExecutorSubActionPushParams, + PushToServiceApiParamsSIR, + ExternalServiceSIR, + Observable, + ObservableTypes, +} from './types'; export const serviceNowCommonFields = [ { @@ -74,6 +81,10 @@ const createMock = (): jest.Mocked => { getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)), getIncident: jest.fn().mockImplementation(() => Promise.resolve({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', short_description: 'title from servicenow', description: 'description from servicenow', }) @@ -95,16 +106,60 @@ const createMock = (): jest.Mocked => { }) ), findIncidents: jest.fn(), + getApplicationInformation: jest.fn().mockImplementation(() => + Promise.resolve({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }) + ), + checkIfApplicationIsInstalled: jest.fn(), + getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'), + checkInstance: jest.fn(), }; return service; }; -const externalServiceMock = { +const createSIRMock = (): jest.Mocked => { + const service = { + ...createMock(), + addObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + value: 'https://example.com', + observable_sys_id: '3', + }) + ), + bulkAddObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve([ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ]) + ), + }; + + return service; +}; + +export const externalServiceMock = { create: createMock, }; -const executorParams: ExecutorSubActionPushParams = { +export const externalServiceSIRMock = { + create: createSIRMock, +}; + +export const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', short_description: 'Incident title', @@ -114,6 +169,8 @@ const executorParams: ExecutorSubActionPushParams = { impact: '3', category: 'software', subcategory: 'os', + correlation_id: 'ruleId', + correlation_display: 'Alerting', }, comments: [ { @@ -127,6 +184,46 @@ const executorParams: ExecutorSubActionPushParams = { ], }; -const apiParams = executorParams; +export const sirParams: PushToServiceApiParamsSIR = { + incident: { + externalId: 'incident-3', + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'ruleId', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + ], +}; + +export const observables: Observable[] = [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + type: ObservableTypes.sha256, + }, + { + value: '127.0.0.1', + type: ObservableTypes.ip4, + }, + { + value: 'https://example.com', + type: ObservableTypes.url, + }, +]; -export { externalServiceMock, executorParams, apiParams }; +export const apiParams = executorParams; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 6fec30803d6d7..dab68bb9d3e9d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), + isLegacy: schema.boolean({ defaultValue: false }), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( @@ -39,6 +40,8 @@ const CommonAttributes = { externalId: schema.nullable(schema.string()), category: schema.nullable(schema.string()), subcategory: schema.nullable(schema.string()), + correlation_id: schema.nullable(schema.string()), + correlation_display: schema.nullable(schema.string()), }; // Schema for ServiceNow Incident Management (ITSM) @@ -56,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - dest_ip: schema.nullable(schema.string()), - malware_hash: schema.nullable(schema.string()), - malware_url: schema.nullable(schema.string()), - source_ip: schema.nullable(schema.string()), + dest_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_hash: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_url: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + source_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), priority: schema.nullable(schema.string()), }), comments: CommentsSchema, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 37bfb662508a2..b8499b01e6a02 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -5,15 +5,16 @@ * 2.0. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { createExternalService } from './service'; import * as utils from '../lib/axios_utils'; -import { ExternalService } from './types'; +import { ExternalService, ServiceNowITSMIncident } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { serviceNowCommonFields, serviceNowChoices } from './mocks'; +import { snExternalServiceConfig } from './config'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios'); @@ -28,24 +29,134 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; -const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); -const table = 'incident'; + +const getImportSetAPIResponse = (update = false) => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: 'https://example.com/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', + sys_id: '1', + }, + ], +}); + +const getImportSetAPIError = () => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + status: 'error', + error_message: 'An error has occurred while importing the incident', + status_message: 'failure', + }, + ], +}); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, + }, + })); + +const mockImportIncident = (update: boolean) => + requestMock.mockImplementationOnce(() => ({ + data: getImportSetAPIResponse(update), + })); + +const mockIncidentResponse = (update: boolean) => + requestMock.mockImplementation(() => ({ + data: { + result: { + sys_id: '1', + number: 'INC01', + ...(update + ? { sys_updated_on: '2020-03-10 12:24:20' } + : { sys_created_on: '2020-03-10 12:24:20' }), + }, + }, + })); + +const createIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(false); + // Get incident response + mockIncidentResponse(false); + + return await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const updateIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(true); + // Get incident response + mockIncidentResponse(true); + + return await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const expectImportedIncident = (update: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { + u_short_description: 'title', + u_description: 'desc', + ...(update ? { elastic_incident_id: '1' } : {}), + }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', + }); +}; describe('ServiceNow service', () => { let service: ExternalService; beforeEach(() => { service = createExternalService( - table, { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ); }); @@ -57,13 +168,13 @@ describe('ServiceNow service', () => { test('throws without url', () => { expect(() => createExternalService( - table, { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -71,13 +182,13 @@ describe('ServiceNow service', () => { test('throws without username', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -85,13 +196,13 @@ describe('ServiceNow service', () => { test('throws without password', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, }, logger, - configurationUtilities + configurationUtilities, + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -116,19 +227,20 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', }); }); test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -140,7 +252,8 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', }); }); @@ -166,214 +279,346 @@ describe('ServiceNow service', () => { }); describe('createIncident', () => { - test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); - - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + // new connectors + describe('import set table', () => { + test('it creates the incident correctly', async () => { + const res = await createIncident(service); + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + test('it should call request with correct arguments', async () => { + await createIncident(service); + expect(requestMock).toHaveBeenCalledTimes(3); + expectImportedIncident(false); }); - }); - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, - }); + const res = await createIncident(service); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, - }); - }); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - 'sn_si_incident', - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities - ); + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); }); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' - ); - }); + test('it creates the incident correctly', async () => { + mockIncidentResponse(false); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); - }); - }); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); - describe('updateIncident', () => { - test('it updates the incident correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + mockIncidentResponse(false); - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, - }); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); }); + }); - test('it should call request with correct arguments', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); - - await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + describe('updateIncident', () => { + // new connectors + describe('import set table', () => { + test('it updates the incident correctly', async () => { + const res = await updateIncident(service); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', - data: { short_description: 'title', description: 'desc' }, + test('it should call request with correct arguments', async () => { + await updateIncident(service); + expectImportedIncident(true); }); - }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - 'sn_si_incident', - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + const res = await updateIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', - data: { short_description: 'title', description: 'desc' }, + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - patchMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.updateIncident({ + test('it updates the incident correctly', async () => { + mockIncidentResponse(true); + const res = await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' - ); - }); + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it creates the comment correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); - const res = await service.updateIncident({ - incidentId: '1', - comment: 'comment-1', - }); + mockIncidentResponse(false); - expect(res).toEqual({ - title: 'INC011', - id: '11', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11', - }); - }); + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); }); }); @@ -388,7 +633,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -402,13 +647,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -420,7 +665,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -456,7 +701,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -470,13 +715,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -489,7 +734,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -513,4 +758,79 @@ describe('ServiceNow service', () => { ); }); }); + + describe('getUrl', () => { + test('it returns the instance url', async () => { + expect(service.getUrl()).toBe('https://example.com'); + }); + }); + + describe('checkInstance', () => { + test('it throws an error if there is no result on data', () => { + const res = { status: 200, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow(); + }); + + test('it does NOT throws an error if the status > 400', () => { + const res = { status: 500, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).not.toThrow(); + }); + + test('it shows the servername', () => { + const res = { + status: 200, + data: {}, + request: { connection: { servername: 'https://example.com' } }, + } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow( + 'There is an issue with your Service Now Instance. Please check https://example.com.' + ); + }); + + describe('getApplicationInformation', () => { + test('it returns the application information', async () => { + mockApplicationVersion(); + const res = await service.getApplicationInformation(); + expect(res).toEqual({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getApplicationInformation()).rejects.toThrow( + '[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown' + ); + }); + }); + + describe('checkIfApplicationIsInstalled', () => { + test('it logs the application information', async () => { + mockApplicationVersion(); + await service.checkIfApplicationIsInstalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0' + ); + }); + + test('it does not log if useOldApi = true', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); + await service.checkIfApplicationIsInstalled(); + expect(requestMock).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 07ed9edc94d39..cb030c7bb6933 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -7,28 +7,35 @@ import axios, { AxiosResponse } from 'axios'; -import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types'; +import { + ExternalServiceCredentials, + ExternalService, + ExternalServiceParamsCreate, + ExternalServiceParamsUpdate, + ImportSetApiResponse, + ImportSetApiResponseError, + ServiceNowIncident, + GetApplicationInfoResponse, + SNProductsConfigValue, + ServiceFactory, +} from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; -import { - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ResponseError, -} from './types'; -import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils'; +import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createServiceError, getPushedDate, prepareIncident } from './utils'; -const API_VERSION = 'v2'; -const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`; +export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; -export const createExternalService = ( - table: string, +export const createExternalService: ServiceFactory = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue ): ExternalService => { - const { apiUrl: url } = config as ServiceNowPublicConfigurationType; + const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; if (!url || !username || !password) { @@ -36,13 +43,26 @@ export const createExternalService = ( } const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; - const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`; - const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; - const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`; + const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`; + const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; + const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`; + /** + * Need to be set the same at: + * x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts + */ + const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`; + const axiosInstance = axios.create({ auth: { username, password }, }); + const useOldApi = !useImportAPI || isLegacy; + + const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); + const getUpdateIncidentUrl = (incidentId: string) => + useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; + const getIncidentViewURL = (id: string) => { // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`; @@ -57,7 +77,7 @@ export const createExternalService = ( }; const checkInstance = (res: AxiosResponse) => { - if (res.status === 200 && res.data.result == null) { + if (res.status >= 200 && res.status < 400 && res.data.result == null) { throw new Error( `There is an issue with your Service Now Instance. Please check ${ res.request?.connection?.servername ?? '' @@ -66,34 +86,70 @@ export const createExternalService = ( } }; - const createErrorMessage = (errorResponse: ResponseError): string => { - if (errorResponse == null) { - return ''; + const isImportSetApiResponseAnError = ( + data: ImportSetApiResponse['result'][0] + ): data is ImportSetApiResponseError['result'][0] => data.status === 'error'; + + const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => { + if (res.result.length === 0) { + throw new Error('Unexpected result'); } - const { error } = errorResponse; - return error != null ? `${error?.message}: ${error?.detail}` : ''; + const data = res.result[0]; + + // Create ResponseError message? + if (isImportSetApiResponseAnError(data)) { + throw new Error(data.error_message); + } }; - const getIncident = async (id: string) => { + /** + * Gets the Elastic SN Application information including the current version. + * It should not be used on legacy connectors. + */ + const getApplicationInformation = async (): Promise => { try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${id}`, + url: getVersionUrl(), logger, configurationUtilities, + method: 'get', }); + checkInstance(res); + return { ...res.data.result }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get incident with id ${id}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, 'Unable to get application version'); + } + }; + + const logApplicationInfo = (scope: string, version: string) => + logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`); + + const checkIfApplicationIsInstalled = async () => { + if (!useOldApi) { + const { version, scope } = await getApplicationInformation(); + logApplicationInfo(scope, version); + } + }; + + const getIncident = async (id: string): Promise => { + try { + const res = await request({ + axios: axiosInstance, + url: `${tableApiIncidentUrl}/${id}`, + logger, + configurationUtilities, + method: 'get', + }); + + checkInstance(res); + + return { ...res.data.result }; + } catch (error) { + throw createServiceError(error, `Unable to get incident with id ${id}`); } }; @@ -101,7 +157,7 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: incidentUrl, + url: tableApiIncidentUrl, logger, params, configurationUtilities, @@ -109,71 +165,80 @@ export const createExternalService = ( checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to find incidents by query'); } }; - const createIncident = async ({ incident }: ExternalServiceParams) => { + const getUrl = () => urlWithoutTrailingSlash; + + const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { + await checkIfApplicationIsInstalled(); + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}`, + url: getCreateIncidentUrl(), logger, method: 'post', - data: { ...(incident as Record) }, + data: prepareIncident(useOldApi, incident), configurationUtilities, }); + checkInstance(res); + + if (!useOldApi) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const insertedIncident = await getIncident(incidentId); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: insertedIncident.number, + id: insertedIncident.sys_id, + pushedDate: getPushedDate(insertedIncident.sys_created_on), + url: getIncidentViewURL(insertedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to create incident'); } }; - const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { - const res = await patch({ + await checkIfApplicationIsInstalled(); + + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${incidentId}`, + url: getUpdateIncidentUrl(incidentId), + // Import Set API supports only POST. + method: useOldApi ? 'patch' : 'post', logger, - data: { ...(incident as Record) }, + data: { + ...prepareIncident(useOldApi, incident), + // elastic_incident_id is used to update the incident when using the Import Set API. + ...(useOldApi ? {} : { elastic_incident_id: incidentId }), + }, configurationUtilities, }); + checkInstance(res); + + if (!useOldApi) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const updatedIncident = await getIncident(id); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: updatedIncident.number, + id: updatedIncident.sys_id, + pushedDate: getPushedDate(updatedIncident.sys_updated_on), + url: getIncidentViewURL(updatedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to update incident with id ${incidentId}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, `Unable to update incident with id ${incidentId}`); } }; @@ -185,17 +250,12 @@ export const createExternalService = ( logger, configurationUtilities, }); + checkInstance(res); + return res.data.result.length > 0 ? res.data.result : []; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get fields'); } }; @@ -210,14 +270,7 @@ export const createExternalService = ( checkInstance(res); return res.data.result; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get choices'); } }; @@ -228,5 +281,9 @@ export const createExternalService = ( getIncident, updateIncident, getChoices, + getUrl, + checkInstance, + getApplicationInformation, + checkIfApplicationIsInstalled, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts new file mode 100644 index 0000000000000..0fc94b6287abd --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { createExternalServiceSIR } from './service_sir'; +import * as utils from '../lib/axios_utils'; +import { ExternalServiceSIR } from './types'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { observables } from './mocks'; +import { snExternalServiceConfig } from './config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' }, + }, + })); + +const getAddObservablesResponse = () => [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, +]; + +const mockAddObservablesResponse = (single: boolean) => { + const res = getAddObservablesResponse(); + requestMock.mockImplementation(() => ({ + data: { + result: single ? res[0] : res, + }, + })); +}; + +const expectAddObservables = (single: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + const url = single + ? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables' + : 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk'; + + const data = single ? observables[0] : observables; + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url, + method: 'post', + data, + }); +}; + +describe('ServiceNow SIR service', () => { + let service: ExternalServiceSIR; + + beforeEach(() => { + service = createExternalServiceSIR( + { + config: { apiUrl: 'https://example.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ) as ExternalServiceSIR; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bulkAddObservableToIncident', () => { + test('it adds multiple observables correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(false); + + const res = await service.bulkAddObservableToIncident(observables, 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()); + expectAddObservables(false); + }); + + test('it adds a single observable correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(true); + + const res = await service.addObservableToIncident(observables[0], 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()[0]); + expectAddObservables(true); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts new file mode 100644 index 0000000000000..fc8d8cc555bc8 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { + ExternalServiceCredentials, + SNProductsConfigValue, + Observable, + ExternalServiceSIR, + ObservableResponse, + ServiceFactory, +} from './types'; + +import { Logger } from '../../../../../../src/core/server'; +import { ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createExternalService } from './service'; +import { createServiceError } from './utils'; + +const getAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`; + +const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; + +export const createExternalServiceSIR: ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +): ExternalServiceSIR => { + const snService = createExternalService( + credentials, + logger, + configurationUtilities, + serviceConfig + ); + + const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const _addObservable = async (data: Observable | Observable[], url: string) => { + snService.checkIfApplicationIsInstalled(); + + const res = await request({ + axios: axiosInstance, + url, + logger, + method: 'post', + data, + configurationUtilities, + }); + + snService.checkInstance(res); + return res.data.result; + }; + + const addObservableToIncident = async ( + observable: Observable, + incidentID: string + ): Promise => { + try { + return await _addObservable( + observable, + getAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observable to security incident with id ${incidentID}` + ); + } + }; + + const bulkAddObservableToIncident = async ( + observables: Observable[], + incidentID: string + ): Promise => { + try { + return await _addObservable( + observables, + getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observables to security incident with id ${incidentID}` + ); + } + }; + return { + ...snService, + addObservableToIncident, + bulkAddObservableToIncident, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 50631cf289a73..ecca1e55e0fec 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -7,6 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { AxiosError, AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { ExecutorParamsSchemaITSM, @@ -78,15 +79,29 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { comments?: ExternalServiceCommentResponse[]; } -export type ExternalServiceParams = Record; +export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; +export type PartialIncident = Partial; + +export interface ExternalServiceParamsCreate { + incident: Incident & Record; +} + +export interface ExternalServiceParamsUpdate { + incidentId: string; + incident: PartialIncident & Record; +} export interface ExternalService { getChoices: (fields: string[]) => Promise; - getIncident: (id: string) => Promise; + getIncident: (id: string) => Promise; getFields: () => Promise; - createIncident: (params: ExternalServiceParams) => Promise; - updateIncident: (params: ExternalServiceParams) => Promise; - findIncidents: (params?: Record) => Promise; + createIncident: (params: ExternalServiceParamsCreate) => Promise; + updateIncident: (params: ExternalServiceParamsUpdate) => Promise; + findIncidents: (params?: Record) => Promise; + getUrl: () => string; + checkInstance: (res: AxiosResponse) => void; + getApplicationInformation: () => Promise; + checkIfApplicationIsInstalled: () => Promise; } export type PushToServiceApiParams = ExecutorSubActionPushParams; @@ -115,10 +130,9 @@ export type ServiceNowSIRIncident = Omit< 'externalId' >; -export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; - export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; + config: Record; secrets: Record; logger: Logger; commentFieldKey: string; @@ -158,12 +172,20 @@ export interface GetChoicesHandlerArgs { params: ExecutorSubActionGetChoicesParams; } -export interface ExternalServiceApi { +export interface ServiceNowIncident { + sys_id: string; + number: string; + sys_created_on: string; + sys_updated_on: string; + [x: string]: unknown; +} + +export interface ExternalServiceAPI { getChoices: (args: GetChoicesHandlerArgs) => Promise; getFields: (args: GetCommonFieldsHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; - getIncident: (args: GetIncidentApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; } export interface ExternalServiceCommentResponse { @@ -173,10 +195,90 @@ export interface ExternalServiceCommentResponse { } type TypeNullOrUndefined = T | null | undefined; -export interface ResponseError { + +export interface ServiceNowError { error: TypeNullOrUndefined<{ message: TypeNullOrUndefined; detail: TypeNullOrUndefined; }>; status: TypeNullOrUndefined; } + +export type ResponseError = AxiosError; + +export interface ImportSetApiResponseSuccess { + import_set: string; + staging_table: string; + result: Array<{ + display_name: string; + display_value: string; + record_link: string; + status: string; + sys_id: string; + table: string; + transform_map: string; + }>; +} + +export interface ImportSetApiResponseError { + import_set: string; + staging_table: string; + result: Array<{ + error_message: string; + status_message: string; + status: string; + transform_map: string; + }>; +} + +export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; +export interface GetApplicationInfoResponse { + id: string; + name: string; + scope: string; + version: string; +} + +export interface SNProductsConfigValue { + table: string; + appScope: string; + useImportAPI: boolean; + importSetTable: string; + commentFieldKey: string; +} + +export type SNProductsConfig = Record; + +export enum ObservableTypes { + ip4 = 'ipv4-addr', + url = 'URL', + sha256 = 'SHA256', +} + +export interface Observable { + value: string; + type: ObservableTypes; +} + +export interface ObservableResponse { + value: string; + observable_sys_id: ObservableTypes; +} + +export interface ExternalServiceSIR extends ExternalService { + addObservableToIncident: ( + observable: Observable, + incidentID: string + ) => Promise; + bulkAddObservableToIncident: ( + observables: Observable[], + incidentID: string + ) => Promise; +} + +export type ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +) => ExternalServiceSIR | ExternalService; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts new file mode 100644 index 0000000000000..87f27da6d213f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AxiosError } from 'axios'; +import { prepareIncident, createServiceError, getPushedDate } from './utils'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('utils', () => { + describe('prepareIncident', () => { + test('it prepares the incident correctly when useOldApi=false', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(false, incident); + expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' }); + }); + + test('it prepares the incident correctly when useOldApi=true', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(true, incident); + expect(newIncident).toEqual(incident); + }); + }); + + describe('createServiceError', () => { + test('it creates an error when the response is null', async () => { + const error = new Error('An error occurred'); + // @ts-expect-error + expect(createServiceError(error, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null' + ); + }); + + test('it creates an error with response correctly', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: { message: 'Denied', detail: 'no access' } } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access' + ); + }); + + test('it creates an error correctly when the ServiceNow error is null', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: null } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response' + ); + }); + }); + + describe('getPushedDate', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('it formats the date correctly if timestamp is provided', async () => { + expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z'); + }); + + test('it formats the date correctly if timestamp is not provided', async () => { + expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts new file mode 100644 index 0000000000000..5b7ca99ffc709 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; +import { FIELD_PREFIX } from './config'; +import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; +import * as i18n from './translations'; + +export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => + useOldApi + ? incident + : Object.entries(incident).reduce( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} as Incident + ); + +const createErrorMessage = (errorResponse?: ServiceNowError): string => { + if (errorResponse == null) { + return 'unknown: errorResponse was null'; + } + + const { error } = errorResponse; + return error != null + ? `${error?.message}: ${error?.detail}` + : 'unknown: no error in error response'; +}; + +export const createServiceError = (error: ResponseError, message: string) => + new Error( + getErrorMessage( + i18n.SERVICENOW, + `${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + +export const getPushedDate = (timestamp?: string) => { + if (timestamp != null) { + return new Date(addTimeZoneToDate(timestamp)).toISOString(); + } + + return new Date().toISOString(); +}; diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts new file mode 100644 index 0000000000000..f20d499716cf0 --- /dev/null +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: Remove when Elastic for ITSM is published. +export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; + +// TODO: Remove when Elastic for Security Operations is published. +export const ENABLE_NEW_SN_SIR_CONNECTOR = true; diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index c094109a43d97..9f8e62c77e3a7 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -165,6 +165,47 @@ describe('successful migrations', () => { }); expect(migratedAction).toEqual(action); }); + + test('set isLegacy config property for .servicenow', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow(); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('set isLegacy config property for .servicenow-sir', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' }); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('it does not set isLegacy config for other connectors', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockData(); + const migratedAction = migration716(action, context); + expect(migratedAction).toEqual(action); + }); }); describe('8.0.0', () => { @@ -306,3 +347,19 @@ function getMockData( type: 'action', }; } + +function getMockDataForServiceNow( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + name: 'abc', + actionTypeId: '.servicenow', + config: { apiUrl: 'https://example.com' }, + secrets: { user: 'test', password: '123' }, + ...overwrites, + }, + id: uuid.v4(), + type: 'action', + }; +} diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index e75f3eb41f2df..688839eb89858 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -59,13 +59,16 @@ export function getActionsMigrations( const migrationActionsFourteen = createEsoMigration( encryptedSavedObjects, (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addisMissingSecretsField) + pipeMigrations(addIsMissingSecretsField) ); - const migrationEmailActionsSixteen = createEsoMigration( + const migrationActionsSixteen = createEsoMigration( encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => doc.attributes.actionTypeId === '.email', - pipeMigrations(setServiceConfigIfNotSet) + (doc): doc is SavedObjectUnsanitizedDoc => + doc.attributes.actionTypeId === '.servicenow' || + doc.attributes.actionTypeId === '.servicenow-sir' || + doc.attributes.actionTypeId === '.email', + pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet) ); const migrationActions800 = createEsoMigration( @@ -79,7 +82,7 @@ export function getActionsMigrations( '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), - '7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'), + '7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), }; } @@ -182,7 +185,7 @@ const setServiceConfigIfNotSet = ( }; }; -const addisMissingSecretsField = ( +const addIsMissingSecretsField = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { return { @@ -194,6 +197,28 @@ const addisMissingSecretsField = ( }; }; +const markOldServiceNowITSMConnectorAsLegacy = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + if ( + doc.attributes.actionTypeId !== '.servicenow' && + doc.attributes.actionTypeId !== '.servicenow-sir' + ) { + return doc; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + config: { + ...doc.attributes.config, + isLegacy: true, + }, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index f894ca23dfbf0..f28926eb52052 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -1,9 +1,9 @@ -Case management in Kibana +# Case management in Kibana [![Issues][issues-shield]][issues-url] -[![Pull Requests][pr-shield]][pr-url] +[![Pull Requests][pr-shield]][pr-url] -# Cases Plugin Docs +# Docs ![Cases Logo][cases-logo] @@ -288,9 +288,9 @@ Connectors of type (`.none`) should have the `fields` attribute set to `null`. -[pr-shield]: https://img.shields.io/github/issues-pr/elangosundar/awesome-README-templates?style=for-the-badge -[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+label%3AFeature%3ACases+-is%3Adraft+is%3Aopen+ -[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge +[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge +[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22 +[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge [issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases [cases-logo]: images/logo.png [configure-img]: images/configure.png diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 948b203af14a8..b4ed4f7db177e 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -16,6 +16,7 @@ import { User, UserAction, UserActionField, + ActionConnector, } from '../api'; export interface CasesUiConfigType { @@ -259,3 +260,5 @@ export interface Ecs { _index?: string; signal?: SignalEcs; } + +export type CaseActionConnector = ActionConnector; diff --git a/x-pack/plugins/cases/public/common/mock/register_connectors.ts b/x-pack/plugins/cases/public/common/mock/register_connectors.ts new file mode 100644 index 0000000000000..42e7cd4a85e40 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/register_connectors.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { CaseActionConnector } from '../../../common'; + +const getUniqueActionTypeIds = (connectors: CaseActionConnector[]) => + new Set(connectors.map((connector) => connector.actionTypeId)); + +export const registerConnectorsToMockActionRegistry = ( + actionTypeRegistry: TriggersAndActionsUIPublicPluginStart['actionTypeRegistry'], + connectors: CaseActionConnector[] +) => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + const uniqueActionTypeIds = getUniqueActionTypeIds(connectors); + uniqueActionTypeIds.forEach((actionTypeId) => + actionTypeRegistry.register( + createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' }) + ) + ); +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx index 0e548fd53c89d..fed23564a3955 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx @@ -19,8 +19,8 @@ import { useKibana } from '../../common/lib/kibana'; import { StatusAll } from '../../containers/types'; import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common'; import { connectorsMock } from '../../containers/mock'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -59,14 +59,10 @@ jest.mock('../../common/lib/kibana', () => { }); describe('AllCasesGeneric ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 015ba877a2749..090ac0d31ed06 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -12,21 +12,17 @@ import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../containers/mock'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { connectors } from '../configure_cases/__mock__'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('ExternalServiceColumn ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); }); it('Not pushed render', () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 3fff43108772d..a387c5eae3834 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -32,8 +32,8 @@ import { useKibana } from '../../common/lib/kibana'; import { AllCasesGeneric as AllCases } from './all_cases_generic'; import { AllCasesProps } from '.'; import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); @@ -148,14 +148,10 @@ describe('AllCasesGeneric', () => { userCanCrud: true, }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index 0bda6fe185093..38923784d862c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { Connectors, Props } from './connectors'; import { TestProviders } from '../../common/mock'; @@ -14,6 +15,7 @@ import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors, actionTypes } from './__mock__'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -35,11 +37,10 @@ describe('Connectors', () => { updateConnectorDisabled: false, }; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; + beforeAll(() => { - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: 'test', - iconClass: 'logoSecurity', - }); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -121,4 +122,33 @@ describe('Connectors', () => { .text() ).toBe('Update My Connector'); }); + + test('it shows the deprecated callout when the connector is legacy', async () => { + render( + , + { + // wrapper: TestProviders produces a TS error + wrapper: ({ children }) => {children}, + } + ); + + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect( + screen.getByText( + 'This connector type is deprecated. Create a new connector or update this connector' + ) + ).toBeInTheDocument(); + }); + + test('it does not shows the deprecated callout when the connector is none', async () => { + render(, { + // wrapper: TestProviders produces a TS error + wrapper: ({ children }) => {children}, + }); + + expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index 40f314a653882..1b575e3ba9334 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -22,6 +22,8 @@ import * as i18n from './translations'; import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; import { Mapping } from './mapping'; import { ActionTypeConnector, ConnectorTypes } from '../../../common'; +import { DeprecatedCallout } from '../connectors/deprecated_callout'; +import { isLegacyConnector } from '../utils'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -53,11 +55,13 @@ const ConnectorsComponent: React.FC = ({ selectedConnector, updateConnectorDisabled, }) => { - const connectorsName = useMemo( - () => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none', + const connector = useMemo( + () => connectors.find((c) => c.id === selectedConnector.id), [connectors, selectedConnector.id] ); + const connectorsName = connector?.name ?? 'none'; + const actionTypeName = useMemo( () => actionTypes.find((c) => c.id === selectedConnector.type)?.name ?? 'Unknown', [actionTypes, selectedConnector.type] @@ -107,6 +111,11 @@ const ConnectorsComponent: React.FC = ({ appendAddConnectorButton={true} /> + {selectedConnector.type !== ConnectorTypes.none && isLegacyConnector(connector) && ( + + + + )} {selectedConnector.type !== ConnectorTypes.none ? ( ; @@ -28,14 +29,10 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -77,7 +74,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-servicenow-1", "inputDisplay": { type="logoSecurity" /> - + My Connector @@ -100,7 +99,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-resilient-2", "inputDisplay": { type="logoSecurity" /> - + My Connector 2 @@ -123,7 +124,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-jira-1", "inputDisplay": { type="logoSecurity" /> - + Jira @@ -146,7 +149,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-servicenow-sir", "inputDisplay": { type="logoSecurity" /> - + My Connector SIR @@ -165,6 +170,43 @@ describe('ConnectorsDropdown', () => { , "value": "servicenow-sir", }, + Object { + "data-test-subj": "dropdown-connector-servicenow-legacy", + "inputDisplay": + + + + + + My Connector + + + + + + , + "value": "servicenow-legacy", + }, ] `); }); @@ -245,4 +287,13 @@ describe('ConnectorsDropdown', () => { ) ).not.toThrowError(); }); + + test('it shows the deprecated tooltip when the connector is legacy', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + const tooltips = screen.getAllByLabelText('Deprecated connector'); + expect(tooltips[0]).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 3cab2afd41f41..f21b3ab3d544f 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -6,14 +6,14 @@ */ import React, { useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiIconTip, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; -import { getConnectorIcon } from '../utils'; +import { getConnectorIcon, isLegacyConnector } from '../utils'; export interface Props { connectors: ActionConnector[]; @@ -79,16 +79,28 @@ const ConnectorsDropdownComponent: React.FC = ({ { value: connector.id, inputDisplay: ( - + - + {connector.name} + {isLegacyConnector(connector) && ( + + + + )} ), 'data-test-subj': `dropdown-connector-${connector.id}`, diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 878d261369340..4a775c78d4ab8 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -162,3 +162,17 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => values: { connectorName }, defaultMessage: 'Update { connectorName }', }); + +export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipTitle', + { + defaultMessage: 'Deprecated connector', + } +); + +export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipContent', + { + defaultMessage: 'Please update your connector', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx index b5d70a6781916..384442814ffef 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -10,22 +10,18 @@ import { mount } from 'enzyme'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { connectors } from '../configure_cases/__mock__'; import { ConnectorCard } from './card'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorCard ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); }); it('it does not throw when accessing the icon if the connector type is not registered', () => { diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx new file mode 100644 index 0000000000000..6b1475e3c4bd0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DeprecatedCallout } from './deprecated_callout'; + +describe('DeprecatedCallout', () => { + test('it renders correctly', () => { + render(); + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect( + screen.getByText( + 'This connector type is deprecated. Create a new connector or update this connector' + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( + 'euiCallOut euiCallOut--warning' + ); + }); + + test('it renders a danger flyout correctly', () => { + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( + 'euiCallOut euiCallOut--danger' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx new file mode 100644 index 0000000000000..937f8406e218a --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCallOut, EuiCallOutProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', + { + defaultMessage: 'Deprecated connector type', + } +); + +const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', + { + defaultMessage: + 'This connector type is deprecated. Create a new connector or update this connector', + } +); + +interface Props { + type?: EuiCallOutProps['color']; +} + +const DeprecatedCalloutComponent: React.FC = ({ type = 'warning' }) => ( + + {LEGACY_CONNECTOR_WARNING_DESC} + +); + +export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index b14842bbf1bbf..008340b6b7e97 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, render, screen } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; import { mount } from 'enzyme'; @@ -127,6 +127,17 @@ describe('ServiceNowITSM Fields', () => { ); }); + test('it shows the deprecated callout when the connector is legacy', async () => { + const legacyConnector = { ...connector, config: { isLegacy: true } }; + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument(); + }); + + test('it does not show the deprecated callout when the connector is not legacy', async () => { + render(); + expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument(); + }); + describe('onChange calls', () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 53c0d32dea1a5..096e450c736c1 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -16,6 +16,8 @@ import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; +import { connectorValidator } from './validator'; +import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -39,6 +41,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); + const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); @@ -149,90 +152,111 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } }, [category, impact, onChange, severity, subcategory, urgency]); - return isEdit ? ( -
- - onChangeCb('urgency', e.target.value)} - /> - - - - - - onChangeCb('severity', e.target.value)} + return ( + <> + {showConnectorWarning && ( + + + + + + )} + {isEdit ? ( +
+ + + + onChangeCb('urgency', e.target.value)} + /> + + + + + + + + onChangeCb('severity', e.target.value)} + /> + + + + + onChangeCb('impact', e.target.value)} + /> + + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null }) + } + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + + -
-
- - - onChangeCb('impact', e.target.value)} - /> - - -
- - - - onChange({ ...fields, category: e.target.value, subcategory: null })} - /> - - - - - onChangeCb('subcategory', e.target.value)} - /> - - - -
- ) : ( - +
+
+ )} + ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index 7d42c90a436f7..aac78b8266fb5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, render, screen } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; import { useKibana } from '../../../common/lib/kibana'; @@ -68,16 +68,16 @@ describe('ServiceNowSIR Fields', () => { wrapper.update(); expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( - 'Destination IP: Yes' + 'Destination IPs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( - 'Source IP: Yes' + 'Source IPs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( - 'Malware URL: Yes' + 'Malware URLs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual( - 'Malware Hash: Yes' + 'Malware Hashes: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual( 'Priority: 1 - Critical' @@ -161,6 +161,17 @@ describe('ServiceNowSIR Fields', () => { ]); }); + test('it shows the deprecated callout when the connector is legacy', async () => { + const legacyConnector = { ...connector, config: { isLegacy: true } }; + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument(); + }); + + test('it does not show the deprecated callout when the connector is not legacy', async () => { + render(); + expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument(); + }); + describe('onChange calls', () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 1f9a7cf7acd64..a7b8aa7b27df5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -17,6 +17,8 @@ import { Choice, Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; +import { connectorValidator } from './validator'; +import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -40,8 +42,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; - const [choices, setChoices] = useState(defaultFields); + const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const onChangeCb = useCallback( ( @@ -166,115 +168,132 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< } }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); - return isEdit ? ( -
- - - - <> - - - onChangeCb('destIp', e.target.checked)} - /> - - - onChangeCb('sourceIp', e.target.checked)} - /> - - - - - onChangeCb('malwareUrl', e.target.checked)} - /> - - - onChangeCb('malwareHash', e.target.checked)} - /> - - - - - - - - - - onChangeCb('priority', e.target.value)} - /> - - - - - - - onChange({ ...fields, category: e.target.value, subcategory: null })} - /> - - - - - onChangeCb('subcategory', e.target.value)} + return ( + <> + {showConnectorWarning && ( + + + + + + )} + {isEdit ? ( +
+ + + + <> + + + onChangeCb('destIp', e.target.checked)} + /> + + + onChangeCb('sourceIp', e.target.checked)} + /> + + + + + onChangeCb('malwareUrl', e.target.checked)} + /> + + + onChangeCb('malwareHash', e.target.checked)} + /> + + + + + + + + + + onChangeCb('priority', e.target.value)} + /> + + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null }) + } + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + + -
-
-
-
- ) : ( - +
+
+ )} + ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts index fc48ecf17f2c6..d9ed86b594ecc 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -30,11 +30,11 @@ export const CHOICES_API_ERROR = i18n.translate( ); export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', { - defaultMessage: 'Malware URL', + defaultMessage: 'Malware URLs', }); export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', { - defaultMessage: 'Malware Hash', + defaultMessage: 'Malware Hashes', }); export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', { @@ -46,11 +46,11 @@ export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.sub }); export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', { - defaultMessage: 'Source IP', + defaultMessage: 'Source IPs', }); export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', { - defaultMessage: 'Destination IP', + defaultMessage: 'Destination IPs', }); export const PRIORITY = i18n.translate( diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts new file mode 100644 index 0000000000000..c098d803276bc --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { connector } from '../mock'; +import { connectorValidator } from './validator'; + +describe('ServiceNow validator', () => { + describe('connectorValidator', () => { + test('it returns an error message if the connector is legacy', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + isLegacy: true, + }, + }; + + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' }); + }); + + test('it does not returns an error message if the connector is not legacy', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + isLegacy: false, + }, + }; + + expect(connectorValidator(invalidConnector)).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts new file mode 100644 index 0000000000000..3f67f25549343 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ValidationConfig } from '../../../common/shared_imports'; +import { CaseActionConnector } from '../../types'; + +/** + * The user can not use a legacy connector + */ + +export const connectorValidator = ( + connector: CaseActionConnector +): ReturnType => { + const { + config: { isLegacy }, + } = connector; + if (isLegacy) { + return { + message: 'Deprecated connector', + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index a2ffd42f2660b..ea7435c2cba45 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -22,8 +22,8 @@ import { TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { useKibana } from '../../common/lib/kibana'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -86,14 +86,10 @@ describe('Connector', () => { return
{children}
; }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/types.ts b/x-pack/plugins/cases/public/components/types.ts index 014afc371e761..07ab5814b082b 100644 --- a/x-pack/plugins/cases/public/components/types.ts +++ b/x-pack/plugins/cases/public/components/types.ts @@ -5,6 +5,4 @@ * 2.0. */ -import { ActionConnector } from '../../common'; - -export type CaseActionConnector = ActionConnector; +export { CaseActionConnector } from '../../common'; diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 5f7480cb84f7c..ac5f4dbdd298e 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -10,7 +10,13 @@ import { ConnectorTypes } from '../../common'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; import { StartPlugins } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; +import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../actions/server/constants/connectors'; export const getConnectorById = ( id: string, @@ -22,6 +28,8 @@ const validators: Record< (connector: CaseActionConnector) => ReturnType > = { [ConnectorTypes.swimlane]: swimlaneConnectorValidator, + [ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator, + [ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator, }; export const getConnectorsFormValidators = ({ @@ -68,3 +76,20 @@ export const getConnectorIcon = ( return emptyResponse; }; + +// TODO: Remove when the applications are certified +export const isLegacyConnector = (connector?: CaseActionConnector) => { + if (connector == null) { + return true; + } + + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { + return true; + } + + if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') { + return true; + } + + return connector.config.isLegacy; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index 833c2cfb3aa7c..d1ae7f310a719 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -71,6 +71,16 @@ export const connectorsMock: ActionConnector[] = [ }, isPreconfigured: false, }, + { + id: 'servicenow-legacy', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + isLegacy: true, + }, + isPreconfigured: false, + }, ]; export const actionTypesMock: ActionTypeConnector[] = [ diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts index 2cc1816e7fa67..ac9dc8839bfb8 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts @@ -10,6 +10,7 @@ import { format } from './itsm_format'; describe('ITSM formatter', () => { const theCase = { + id: 'case-id', connector: { fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' }, }, @@ -17,7 +18,11 @@ describe('ITSM formatter', () => { it('it formats correctly', async () => { const res = await format(theCase, []); - expect(res).toEqual(theCase.connector.fields); + expect(res).toEqual({ + ...theCase.connector.fields, + correlation_display: 'Elastic Case', + correlation_id: 'case-id', + }); }); it('it formats correctly when fields do not exist ', async () => { @@ -29,6 +34,8 @@ describe('ITSM formatter', () => { impact: null, category: null, subcategory: null, + correlation_display: 'Elastic Case', + correlation_id: null, }); }); }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts index bc9d50026d1f8..1859ea1246f21 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts @@ -16,5 +16,13 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => { category = null, subcategory = null, } = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; - return { severity, urgency, impact, category, subcategory }; + return { + severity, + urgency, + impact, + category, + subcategory, + correlation_id: theCase.id ?? null, + correlation_display: 'Elastic Case', + }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts index fa103d4c1142d..b09272d0a5505 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts @@ -10,6 +10,7 @@ import { format } from './sir_format'; describe('ITSM formatter', () => { const theCase = { + id: 'case-id', connector: { fields: { destIp: true, @@ -26,13 +27,15 @@ describe('ITSM formatter', () => { it('it formats correctly without alerts', async () => { const res = await format(theCase, []); expect(res).toEqual({ - dest_ip: null, - source_ip: null, + dest_ip: [], + source_ip: [], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: null, - malware_url: null, + malware_hash: [], + malware_url: [], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -40,13 +43,15 @@ describe('ITSM formatter', () => { const invalidFields = { connector: { fields: null } } as CaseResponse; const res = await format(invalidFields, []); expect(res).toEqual({ - dest_ip: null, - source_ip: null, + dest_ip: [], + source_ip: [], category: null, subcategory: null, - malware_hash: null, - malware_url: null, + malware_hash: [], + malware_url: [], priority: null, + correlation_display: 'Elastic Case', + correlation_id: null, }); }); @@ -75,14 +80,18 @@ describe('ITSM formatter', () => { ]; const res = await format(theCase, alerts); expect(res).toEqual({ - dest_ip: '192.168.1.1,192.168.1.4', - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: ['192.168.1.1', '192.168.1.4'], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: - '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: [ + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', + ], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -111,13 +120,15 @@ describe('ITSM formatter', () => { ]; const res = await format(theCase, alerts); expect(res).toEqual({ - dest_ip: '192.168.1.1', - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: ['192.168.1.1'], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: ['9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -152,13 +163,15 @@ describe('ITSM formatter', () => { const res = await format(newCase, alerts); expect(res).toEqual({ - dest_ip: null, - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: [], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: null, - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: [], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts index b48a1b7f734c8..9108408c4d089 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts @@ -32,11 +32,11 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { malware_url: new Set(), }; - let sirFields: Record = { - dest_ip: null, - source_ip: null, - malware_hash: null, - malware_url: null, + let sirFields: Record = { + dest_ip: [], + source_ip: [], + malware_hash: [], + malware_url: [], }; const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter( @@ -44,18 +44,17 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { ); if (fieldsToAdd.length > 0) { - sirFields = alerts.reduce>((acc, alert) => { + sirFields = alerts.reduce>((acc, alert) => { fieldsToAdd.forEach((alertField) => { const field = get(alertFieldMapping[alertField].alertPath, alert); if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); acc = { ...acc, - [alertFieldMapping[alertField].sirFieldKey]: `${ - acc[alertFieldMapping[alertField].sirFieldKey] != null - ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` - : field - }`, + [alertFieldMapping[alertField].sirFieldKey]: [ + ...acc[alertFieldMapping[alertField].sirFieldKey], + field, + ], }; } }); @@ -68,5 +67,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { category, subcategory, priority, + correlation_id: theCase.id ?? null, + correlation_display: 'Elastic Case', }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/types.ts b/x-pack/plugins/cases/server/connectors/servicenow/types.ts index 2caebc3dab316..b0e71cbe5e743 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/types.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/types.ts @@ -8,13 +8,18 @@ import { ServiceNowITSMFieldsType } from '../../../common'; import { ICasesConnector } from '../types'; -export interface ServiceNowSIRFieldsType { - dest_ip: string | null; - source_ip: string | null; +interface CorrelationValues { + correlation_id: string | null; + correlation_display: string | null; +} + +export interface ServiceNowSIRFieldsType extends CorrelationValues { + dest_ip: string[] | null; + source_ip: string[] | null; category: string | null; subcategory: string | null; - malware_hash: string | null; - malware_url: string | null; + malware_hash: string[] | null; + malware_url: string[] | null; priority: string | null; } @@ -26,7 +31,9 @@ export type AlertFieldMappingAndValues = Record< // ServiceNow ITSM export type ServiceNowITSMCasesConnector = ICasesConnector; -export type ServiceNowITSMFormat = ICasesConnector['format']; +export type ServiceNowITSMFormat = ICasesConnector< + ServiceNowITSMFieldsType & CorrelationValues +>['format']; export type ServiceNowITSMGetMapping = ICasesConnector['getMapping']; // ServiceNow SIR diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index d2120faf09dfb..51511fad90b30 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -301,6 +301,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.swimlane', '.webhook', '.servicenow', + '.servicenow-sir', '.jira', '.resilient', '.teams', diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index aa1bd7a5db5cc..a53e37f363d05 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getServiceNowConnector } from '../../objects/case'; +import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../objects/case'; import { SERVICE_NOW_MAPPING, TOASTER } from '../../screens/configure_cases'; @@ -43,8 +43,16 @@ describe('Cases connectors', () => { id: '123', owner: 'securitySolution', }; + + const snConnector = getServiceNowConnector(); + beforeEach(() => { cleanKibana(); + cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, { + statusCode: 200, + body: getServiceNowITSMHealthResponse(), + }); + cy.intercept('POST', '/api/actions/connector').as('createConnector'); cy.intercept('POST', '/api/cases/configure', (req) => { const connector = req.body.connector; @@ -52,6 +60,7 @@ describe('Cases connectors', () => { res.send(200, { ...configureResult, connector }); }); }).as('saveConnector'); + cy.intercept('GET', '/api/cases/configure', (req) => { req.reply((res) => { const resBody = @@ -77,7 +86,7 @@ describe('Cases connectors', () => { loginAndWaitForPageWithoutDateRange(CASES_URL); goToEditExternalConnection(); openAddNewConnectorOption(); - addServiceNowConnector(getServiceNowConnector()); + addServiceNowConnector(snConnector); cy.wait('@createConnector').then(({ response }) => { cy.wrap(response!.statusCode).should('eql', 200); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index af9b34f542046..b0bfdbf16c705 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -44,6 +44,14 @@ export interface IbmResilientConnectorOptions { incidentTypes: string[]; } +interface ServiceNowHealthResponse { + result: { + name: string; + scope: string; + version: string; + }; +} + export const getCase1 = (): TestCase => ({ name: 'This is the title of the case', tags: ['Tag1', 'Tag2'], @@ -60,6 +68,14 @@ export const getServiceNowConnector = (): Connector => ({ password: 'password', }); +export const getServiceNowITSMHealthResponse = (): ServiceNowHealthResponse => ({ + result: { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }, +}); + export const getJiraConnectorOptions = (): JiraConnectorOptions => ({ issueType: '10006', priority: 'High', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 642668bcecf34..54ffd60d0dc16 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25052,7 +25052,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "選択肢を取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "緊急", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "ServiceNow ITSMでインシデントを作成します。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4d86994c0fb84..55e4a913c0e09 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25480,7 +25480,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "无法获取选项", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "紧急性", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "在 ServiceNow ITSM 中创建事件。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index 0b446b99c93dc..a96e1fc3dcb5d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -35,6 +35,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -66,6 +68,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -99,6 +103,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe( @@ -132,6 +138,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy(); @@ -165,6 +173,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(true); @@ -199,6 +209,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(false); @@ -223,6 +235,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -245,6 +259,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -268,6 +284,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index e804ce2a9f54d..9ef498334ad3d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -71,6 +71,8 @@ describe('IndexActionConnectorFields renders', () => { editActionSecrets: () => {}, errors: { index: [] }, readOnly: false, + setCallbacks: () => {}, + isEdit: false, }; const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index be5250ccf8b29..4859c25adcc06 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -34,6 +34,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -74,6 +76,8 @@ describe('JiraActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); @@ -104,6 +108,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -125,6 +131,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -152,6 +160,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index 86347de528a01..8be15ddaa6bca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -33,6 +33,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -61,6 +63,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -86,6 +90,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -112,6 +118,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index bbd237a7cec89..35891f513be6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -34,6 +34,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -74,6 +76,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -105,6 +109,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -126,6 +132,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -153,6 +161,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts index ba820efc8111f..4b67d256d99bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -6,7 +6,7 @@ */ import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; -import { getChoices } from './api'; +import { getChoices, getAppInfo } from './api'; const choicesResponse = { status: 'ok', @@ -44,10 +44,27 @@ const choicesResponse = { ], }; +const applicationInfoData = { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, +}; + +const applicationInfoResponse = { + ok: true, + status: 200, + json: async () => applicationInfoData, +}; + describe('ServiceNow API', () => { const http = httpServiceMock.createStartContract(); + let fetchMock: jest.SpyInstance>; - beforeEach(() => jest.resetAllMocks()); + beforeAll(() => { + fetchMock = jest.spyOn(window, 'fetch'); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); describe('getChoices', () => { test('should call get choices API', async () => { @@ -67,4 +84,96 @@ describe('ServiceNow API', () => { }); }); }); + + describe('getAppInfo', () => { + test('should call getAppInfo API for ITSM', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + const res = await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }); + + expect(res).toEqual(applicationInfoData.result); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + { + signal: abortCtrl.signal, + method: 'GET', + headers: { Authorization: 'Basic dGVzdDp0ZXN0' }, + } + ); + }); + + test('should call getAppInfo API correctly for SIR', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + const res = await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow-sir', + }); + + expect(res).toEqual(applicationInfoData.result); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + { + signal: abortCtrl.signal, + method: 'GET', + headers: { Authorization: 'Basic dGVzdDp0ZXN0' }, + } + ); + }); + + it('returns an error when the response fails', async () => { + expect.assertions(1); + + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => applicationInfoResponse.json, + }); + + await expect(() => + getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }) + ).rejects.toThrow('Received status:'); + }); + + it('returns an error when parsing the json fails', async () => { + expect.assertions(1); + + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad'); + }, + }); + + await expect(() => + getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }) + ).rejects.toThrow('bad'); + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 62347580e75ca..32a2d0296d4c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -6,7 +6,11 @@ */ import { HttpSetup } from 'kibana/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config'; import { BASE_ACTION_API_PATH } from '../../../constants'; +import { API_INFO_ERROR } from './translations'; +import { AppInfo, RESTApiError } from './types'; export async function getChoices({ http, @@ -29,3 +33,43 @@ export async function getChoices({ } ); } + +/** + * The app info url should be the same as at: + * x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts + */ +const getAppInfoUrl = (url: string, scope: string) => `${url}/api/${scope}/elastic_api/health`; + +export async function getAppInfo({ + signal, + apiUrl, + username, + password, + actionTypeId, +}: { + signal: AbortSignal; + apiUrl: string; + username: string; + password: string; + actionTypeId: string; +}): Promise { + const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; + const config = snExternalServiceConfig[actionTypeId]; + const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash, config.appScope ?? ''), { + method: 'GET', + signal, + headers: { + Authorization: 'Basic ' + btoa(username + ':' + password), + }, + }); + + if (!response.ok) { + throw new Error(API_INFO_ERROR(response.status)); + } + + const data = await response.json(); + + return { + ...data.result, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx new file mode 100644 index 0000000000000..67c3238b04774 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ApplicationRequiredCallout } from './application_required_callout'; + +describe('ApplicationRequiredCallout', () => { + test('it renders the callout', () => { + render(); + expect(screen.getByText('Elastic ServiceNow App not installed')).toBeInTheDocument(); + expect( + screen.getByText('Please go to the ServiceNow app store and install the application') + ).toBeInTheDocument(); + }); + + test('it renders the ServiceNow store button', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + test('it renders an error message if provided', () => { + render(); + expect(screen.getByText('Error message: Denied')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx new file mode 100644 index 0000000000000..561dae95fe1b7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SNStoreButton } from './sn_store_button'; + +const content = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.content', + { + defaultMessage: 'Please go to the ServiceNow app store and install the application', + } +); + +const ERROR_MESSAGE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.errorMessage', + { + defaultMessage: 'Error message', + } +); + +interface Props { + message?: string | null; +} + +const ApplicationRequiredCalloutComponent: React.FC = ({ message }) => { + return ( + <> + + +

{content}

+ {message && ( +

+ {ERROR_MESSAGE}: {message} +

+ )} + +
+ + + ); +}; + +export const ApplicationRequiredCallout = memo(ApplicationRequiredCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..9d5fafbf5a0ea --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}'; +export const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx new file mode 100644 index 0000000000000..caee946524265 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiFieldText, + EuiSpacer, + EuiTitle, + EuiFieldPassword, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { useKibana } from '../../../../common/lib/kibana'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; +import * as i18n from './translations'; +import { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; +} + +const CredentialsComponent: React.FC = ({ + action, + errors, + readOnly, + isLoading, + editActionSecrets, + editActionConfig, +}) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); + const isUsernameInvalid = isFieldInvalid(username, errors.username); + const isPasswordInvalid = isFieldInvalid(password, errors.password); + + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + [editActionConfig] + ); + + const handleOnChangeSecretConfig = useCallback( + (key: string, value: string) => editActionSecrets(key, value), + [editActionSecrets] + ); + + return ( + <> + + + +

{i18n.SN_INSTANCE_LABEL}

+
+

+ + {i18n.SETUP_DEV_INSTANCE} + + ), + }} + /> +

+
+ + + handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + disabled={isLoading} + /> + + +
+ + + + +

{i18n.AUTHENTICATION_LABEL}

+
+
+
+ + + + + {getEncryptedFieldNotifyLabel( + !action.id, + 2, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + + + + + + handleOnChangeSecretConfig('username', evt.target.value)} + onBlur={() => { + if (!username) { + editActionSecrets('username', ''); + } + }} + disabled={isLoading} + /> + + + + + + + + handleOnChangeSecretConfig('password', evt.target.value)} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + disabled={isLoading} + /> + + + + + ); +}; + +export const Credentials = memo(CredentialsComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx new file mode 100644 index 0000000000000..767b38ebcf6ad --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { DeprecatedCallout } from './deprecated_callout'; + +describe('DeprecatedCallout', () => { + const onMigrate = jest.fn(); + + test('it renders correctly', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + }); + + test('it calls onMigrate when pressing the button', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(onMigrate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx new file mode 100644 index 0000000000000..101d1572a67ad --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + onMigrate: () => void; +} + +const DeprecatedCalloutComponent: React.FC = ({ onMigrate }) => { + return ( + <> + + + + {i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate', + { + defaultMessage: 'update this connector.', + } + )} + + ), + }} + /> + + + + ); +}; + +export const DeprecatedCallout = memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts new file mode 100644 index 0000000000000..e37d8dd3b4147 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isRESTApiError, isFieldInvalid } from './helpers'; + +describe('helpers', () => { + describe('isRESTApiError', () => { + const resError = { error: { message: 'error', detail: 'access denied' }, status: '401' }; + + test('should return true if the error is RESTApiError', async () => { + expect(isRESTApiError(resError)).toBeTruthy(); + }); + + test('should return true if there is failure status', async () => { + // @ts-expect-error + expect(isRESTApiError({ status: 'failure' })).toBeTruthy(); + }); + + test('should return false if there is no error', async () => { + // @ts-expect-error + expect(isRESTApiError({ whatever: 'test' })).toBeFalsy(); + }); + }); + + describe('isFieldInvalid', () => { + test('should return true if the field is invalid', async () => { + expect(isFieldInvalid('description', ['required'])).toBeTruthy(); + }); + + test('should return if false the field is not defined', async () => { + expect(isFieldInvalid(undefined, ['required'])).toBeFalsy(); + }); + + test('should return if false the error is not defined', async () => { + // @ts-expect-error + expect(isFieldInvalid('description', undefined)).toBeFalsy(); + }); + + test('should return if false the error is empty', async () => { + expect(isFieldInvalid('description', [])).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index 314d224491128..ca557b31c4f4f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -6,7 +6,38 @@ */ import { EuiSelectOption } from '@elastic/eui'; -import { Choice } from './types'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/constants/connectors'; +import { IErrorObject } from '../../../../../public/types'; +import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types'; export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => choices.map((choice) => ({ value: choice.value, text: choice.label })); + +export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError => + (res as RESTApiError).error != null || (res as RESTApiError).status === 'failure'; + +export const isFieldInvalid = ( + field: string | undefined, + error: string | IErrorObject | string[] +): boolean => error !== undefined && error.length > 0 && field !== undefined; + +// TODO: Remove when the applications are certified +export const isLegacyConnector = (connector: ServiceNowActionConnector) => { + if (connector == null) { + return true; + } + + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { + return true; + } + + if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') { + return true; + } + + return connector.config.isLegacy; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx new file mode 100644 index 0000000000000..8e1c1820920c5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { InstallationCallout } from './installation_callout'; + +describe('DeprecatedCallout', () => { + test('it renders correctly', () => { + render(); + expect( + screen.getByText( + 'To use this connector, you must first install the Elastic App from the ServiceNow App Store' + ) + ).toBeInTheDocument(); + }); + + test('it renders the button', () => { + render(); + expect(screen.getByRole('link')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx new file mode 100644 index 0000000000000..064207910568f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; + +import * as i18n from './translations'; +import { SNStoreButton } from './sn_store_button'; + +const InstallationCalloutComponent: React.FC = () => { + return ( + <> + + + + + + + ); +}; + +export const InstallationCallout = memo(InstallationCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index f1516f880dce4..b40db9c2dabda 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -43,6 +43,7 @@ describe('servicenow connector validation', () => { isPreconfigured: false, config: { apiUrl: 'https://dev94428.service-now.com/', + isLegacy: false, }, } as ServiceNowActionConnector; @@ -50,6 +51,7 @@ describe('servicenow connector validation', () => { config: { errors: { apiUrl: [], + isLegacy: [], }, }, secrets: { @@ -77,6 +79,7 @@ describe('servicenow connector validation', () => { config: { errors: { apiUrl: ['URL is required.'], + isLegacy: [], }, }, secrets: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 24e2a87d42357..bb4a645f10bbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -27,6 +27,7 @@ const validateConnector = async ( const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), + isLegacy: new Array(), }; const secretsErrors = { username: new Array(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 4993c51f350ad..02f3ae47728ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -33,6 +33,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect( @@ -57,8 +59,7 @@ describe('ServiceNowActionConnectorFields renders', () => { name: 'servicenow', config: { apiUrl: 'https://test/', - incidentConfiguration: { mapping: [] }, - isCaseOwned: true, + isLegacy: false, }, } as ServiceNowActionConnector; const wrapper = mountWithIntl( @@ -69,6 +70,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); @@ -91,6 +94,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -112,6 +117,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -138,6 +145,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 29a6bca4b16ab..2cf738c5e0c13 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -5,162 +5,142 @@ * 2.0. */ -import React, { useCallback } from 'react'; - -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldPassword, - EuiSpacer, - EuiLink, - EuiTitle, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useCallback, useEffect, useState } from 'react'; + import { ActionConnectorFieldsProps } from '../../../../types'; import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; import { useKibana } from '../../../../common/lib/kibana'; -import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; +import { DeprecatedCallout } from './deprecated_callout'; +import { useGetAppInfo } from './use_get_app_info'; +import { ApplicationRequiredCallout } from './application_required_callout'; +import { isRESTApiError, isLegacyConnector } from './helpers'; +import { InstallationCallout } from './installation_callout'; +import { UpdateConnectorModal } from './update_connector_modal'; +import { updateActionConnector } from '../../../lib/action_connector_api'; +import { Credentials } from './credentials'; const ServiceNowConnectorFields: React.FC> = - ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { - const { docLinks } = useKibana().services; + ({ + action, + editActionSecrets, + editActionConfig, + errors, + consumer, + readOnly, + setCallbacks, + isEdit, + }) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; const { apiUrl } = action.config; + const { username, password } = action.secrets; + const isOldConnector = isLegacyConnector(action); - const isApiUrlInvalid: boolean = - errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; + const [showModal, setShowModal] = useState(false); - const { username, password } = action.secrets; + const { fetchAppInfo, isLoading } = useGetAppInfo({ + actionTypeId: action.actionTypeId, + }); - const isUsernameInvalid: boolean = - errors.username !== undefined && errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = - errors.password !== undefined && errors.password.length > 0 && password !== undefined; + const [applicationRequired, setApplicationRequired] = useState(false); + const [applicationInfoErrorMsg, setApplicationInfoErrorMsg] = useState(null); - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); + const getApplicationInfo = useCallback(async () => { + setApplicationRequired(false); + setApplicationInfoErrorMsg(null); + + try { + const res = await fetchAppInfo(action); + if (isRESTApiError(res)) { + throw new Error(res.error?.message ?? i18n.UNKNOWN); + } + + return res; + } catch (e) { + setApplicationRequired(true); + setApplicationInfoErrorMsg(e.message); + // We need to throw here so the connector will be not be saved. + throw e; + } + }, [action, fetchAppInfo]); - const handleOnChangeSecretConfig = useCallback( - (key: string, value: string) => editActionSecrets(key, value), - [editActionSecrets] + const beforeActionConnectorSave = useCallback(async () => { + if (!isOldConnector) { + await getApplicationInfo(); + } + }, [getApplicationInfo, isOldConnector]); + + useEffect( + () => setCallbacks({ beforeActionConnectorSave }), + [beforeActionConnectorSave, setCallbacks] ); + + const onMigrateClick = useCallback(() => setShowModal(true), []); + const onModalCancel = useCallback(() => setShowModal(false), []); + + const onModalConfirm = useCallback(async () => { + await getApplicationInfo(); + await updateActionConnector({ + http, + connector: { + name: action.name, + config: { apiUrl, isLegacy: false }, + secrets: { username, password }, + }, + id: action.id, + }); + + editActionConfig('isLegacy', false); + setShowModal(false); + + toasts.addSuccess({ + title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), + text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, + }); + }, [ + getApplicationInfo, + http, + action.name, + action.id, + apiUrl, + username, + password, + editActionConfig, + toasts, + ]); + return ( <> - - - - - - } - > - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - /> - - - - - - - -

{i18n.AUTHENTICATION_LABEL}

-
-
-
- - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - /> - - - - - - - - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - /> - - - + {showModal && ( + + )} + {!isOldConnector && } + {isOldConnector && } + + {applicationRequired && !isOldConnector && ( + + )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index e864a8d3fd114..30e09356e95dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -31,6 +31,8 @@ const actionParams = { category: 'software', subcategory: 'os', externalId: null, + correlation_id: 'alertID', + correlation_display: 'Alerting', }, comments: [], }, @@ -144,7 +146,10 @@ describe('ServiceNowITSMParamsFields renders', () => { }; mount(); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); @@ -166,7 +171,10 @@ describe('ServiceNowITSMParamsFields renders', () => { wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index b243afb375e6d..81428cd7f0a73 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -13,16 +13,18 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiSwitch, } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; -import { ServiceNowITSMActionParams, Choice, Fields } from './types'; +import { ServiceNowITSMActionParams, Choice, Fields, ServiceNowActionConnector } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; -import { choicesToEuiOptions } from './helpers'; +import { choicesToEuiOptions, isLegacyConnector } from './helpers'; import * as i18n from './translations'; +import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -42,6 +44,8 @@ const ServiceNowParamsFields: React.FunctionComponent< notifications: { toasts }, } = useKibana().services; + const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( () => @@ -53,8 +57,13 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); + const hasUpdateIncident = + incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; + const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); + const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -90,6 +99,14 @@ const ServiceNowParamsFields: React.FunctionComponent< ); }, []); + const onUpdateIncidentSwitchChange = useCallback(() => { + const newCorrelationID = !updateIncident + ? UPDATE_INCIDENT_VARIABLE + : NOT_UPDATE_INCIDENT_VARIABLE; + editSubActionProperty('correlation_id', newCorrelationID); + setUpdateIncident(!updateIncident); + }, [editSubActionProperty, updateIncident]); + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); @@ -119,7 +136,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -136,7 +153,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -236,25 +253,43 @@ const ServiceNowParamsFields: React.FunctionComponent<
- 0 && - incident.short_description !== undefined - } - label={i18n.SHORT_DESCRIPTION_LABEL} - > - - + + + 0 && + incident.short_description !== undefined + } + label={i18n.SHORT_DESCRIPTION_LABEL} + > + + + + {!isOldConnector && ( + + + + + + )} + + { }; mount(); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); @@ -196,7 +201,10 @@ describe('ServiceNowSIRParamsFields renders', () => { wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 0ba52014fa1f9..7b7cfc67d9971 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiSwitch, } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; @@ -21,8 +22,9 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import * as i18n from './translations'; import { useGetChoices } from './use_get_choices'; -import { ServiceNowSIRActionParams, Fields, Choice } from './types'; -import { choicesToEuiOptions } from './helpers'; +import { ServiceNowSIRActionParams, Fields, Choice, ServiceNowActionConnector } from './types'; +import { choicesToEuiOptions, isLegacyConnector } from './helpers'; +import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -31,6 +33,14 @@ const defaultFields: Fields = { priority: [], }; +const valuesToString = (value: string | string[] | null): string | undefined => { + if (Array.isArray(value)) { + return value.join(','); + } + + return value ?? undefined; +}; + const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { @@ -39,6 +49,8 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< notifications: { toasts }, } = useKibana().services; + const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( () => @@ -50,8 +62,13 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); + const hasUpdateIncident = + incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; + const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); + const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -87,6 +104,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< ); }, []); + const onUpdateIncidentSwitchChange = useCallback(() => { + const newCorrelationID = !updateIncident + ? UPDATE_INCIDENT_VARIABLE + : NOT_UPDATE_INCIDENT_VARIABLE; + editSubActionProperty('correlation_id', newCorrelationID); + setUpdateIncident(!updateIncident); + }, [editSubActionProperty, updateIncident]); + const { isLoading: isLoadingChoices } = useGetChoices({ http, toastNotifications: toasts, @@ -115,7 +140,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -132,7 +157,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -162,48 +187,48 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'short_description'} - inputTargetValue={incident?.short_description ?? undefined} + inputTargetValue={incident?.short_description} errors={errors['subActionParams.incident.short_description'] as string[]} /> - + - + - + - + @@ -277,6 +302,18 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined} label={i18n.COMMENTS_LABEL} /> + + {!isOldConnector && ( + + + + )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx new file mode 100644 index 0000000000000..fe73653234170 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SNStoreButton } from './sn_store_button'; + +describe('SNStoreButton', () => { + test('it renders the button', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + test('it renders a danger button', () => { + render(); + expect(screen.getByRole('link')).toHaveClass('euiButton--danger'); + }); + + test('it renders with correct href', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx new file mode 100644 index 0000000000000..5921f679d3f50 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiButtonProps, EuiButton } from '@elastic/eui'; + +import * as i18n from './translations'; + +const STORE_URL = 'https://store.servicenow.com/'; + +interface Props { + color: EuiButtonProps['color']; +} + +const SNStoreButtonComponent: React.FC = ({ color }) => { + return ( + + {i18n.VISIT_SN_STORE} + + ); +}; + +export const SNStoreButton = memo(SNStoreButtonComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index ea646b896f5e9..90292a35a88df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -10,7 +10,14 @@ import { i18n } from '@kbn/i18n'; export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', { - defaultMessage: 'URL', + defaultMessage: 'ServiceNow instance URL', + } +); + +export const API_URL_HELPTEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText', + { + defaultMessage: 'Include the full URL', } ); @@ -53,7 +60,7 @@ export const REMEMBER_VALUES_LABEL = i18n.translate( export const REENTER_VALUES_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel', { - defaultMessage: 'Username and password are encrypted. Please reenter values for these fields.', + defaultMessage: 'You will need to re-authenticate each time you edit the connector', } ); @@ -95,14 +102,28 @@ export const TITLE_REQUIRED = i18n.translate( export const SOURCE_IP_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle', { - defaultMessage: 'Source IP', + defaultMessage: 'Source IPs', + } +); + +export const SOURCE_IP_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPHelpText', + { + defaultMessage: 'List of source IPs (comma, or pipe delimited)', } ); export const DEST_IP_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle', { - defaultMessage: 'Destination IP', + defaultMessage: 'Destination IPs', + } +); + +export const DEST_IP_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destIPHelpText', + { + defaultMessage: 'List of destination IPs (comma, or pipe delimited)', } ); @@ -137,14 +158,28 @@ export const COMMENTS_LABEL = i18n.translate( export const MALWARE_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle', { - defaultMessage: 'Malware URL', + defaultMessage: 'Malware URLs', + } +); + +export const MALWARE_URL_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLHelpText', + { + defaultMessage: 'List of malware URLs (comma, or pipe delimited)', } ); export const MALWARE_HASH_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', { - defaultMessage: 'Malware Hash', + defaultMessage: 'Malware Hashes', + } +); + +export const MALWARE_HASH_HELP_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText', + { + defaultMessage: 'List of malware hashes (comma, or pipe delimited)', } ); @@ -196,3 +231,91 @@ export const PRIORITY_LABEL = i18n.translate( defaultMessage: 'Priority', } ); + +export const API_INFO_ERROR = (status: number) => + i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiInfoError', { + values: { status }, + defaultMessage: 'Received status: {status} when attempting to get application information', + }); + +export const INSTALL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.install', + { + defaultMessage: 'install', + } +); + +export const INSTALLATION_CALLOUT_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle', + { + defaultMessage: + 'To use this connector, you must first install the Elastic App from the ServiceNow App Store', + } +); + +export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) => + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle', + { + defaultMessage: 'Migrated connector {connectorName}', + values: { + connectorName, + }, + } + ); + +export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText', + { + defaultMessage: 'Connector has been successfully migrated.', + } +); + +export const VISIT_SN_STORE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.visitSNStore', + { + defaultMessage: 'Visit ServiceNow app store', + } +); + +export const SETUP_DEV_INSTANCE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.setupDevInstance', + { + defaultMessage: 'setup a developer instance', + } +); + +export const SN_INSTANCE_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.snInstanceLabel', + { + defaultMessage: 'ServiceNow instance', + } +); + +export const UNKNOWN = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unknown', + { + defaultMessage: 'UNKNOWN', + } +); + +export const UPDATE_INCIDENT_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentCheckboxLabel', + { + defaultMessage: 'Update incident', + } +); + +export const ON = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOn', + { + defaultMessage: 'On', + } +); + +export const OFF = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOff', + { + defaultMessage: 'Off', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index f252f4648e670..b24883359dde5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -29,6 +29,7 @@ export interface ServiceNowSIRActionParams { export interface ServiceNowConfig { apiUrl: string; + isLegacy: boolean; } export interface ServiceNowSecrets { @@ -44,3 +45,17 @@ export interface Choice { } export type Fields = Record; +export interface AppInfo { + id: string; + name: string; + scope: string; + version: string; +} + +export interface RESTApiError { + error: { + message: string; + detail: string; + }; + status: string; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx new file mode 100644 index 0000000000000..b9d660f16dff7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiCallOut, + EuiTextColor, + EuiHorizontalRule, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { ServiceNowActionConnector } from './types'; +import { Credentials } from './credentials'; +import { isFieldInvalid } from './helpers'; +import { ApplicationRequiredCallout } from './application_required_callout'; + +const title = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', + { + defaultMessage: 'Update ServiceNow connector', + } +); + +const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', + { + defaultMessage: 'Cancel', + } +); + +const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', + { + defaultMessage: 'Update', + } +); + +const calloutTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle', + { + defaultMessage: + 'The Elastic App from the ServiceNow App Store must be installed prior to running the update.', + } +); + +const warningMessage = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage', + { + defaultMessage: 'This will update all instances of this connector. This can not be reversed.', + } +); + +interface Props { + action: ActionConnectorFieldsProps['action']; + applicationInfoErrorMsg: string | null; + errors: ActionConnectorFieldsProps['errors']; + isLoading: boolean; + readOnly: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; + onCancel: () => void; + onConfirm: () => void; +} + +const UpdateConnectorModalComponent: React.FC = ({ + action, + applicationInfoErrorMsg, + errors, + isLoading, + readOnly, + editActionSecrets, + editActionConfig, + onCancel, + onConfirm, +}) => { + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const hasErrorsOrEmptyFields = + apiUrl === undefined || + username === undefined || + password === undefined || + isFieldInvalid(apiUrl, errors.apiUrl) || + isFieldInvalid(username, errors.username) || + isFieldInvalid(password, errors.password); + + return ( + + + +

{title}

+
+
+ + + + + + + + + + + {warningMessage} + + + + + {applicationInfoErrorMsg && ( + + )} + + + + + {cancelButtonText} + + {confirmButtonText} + + +
+ ); +}; + +export const UpdateConnectorModal = memo(UpdateConnectorModalComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx new file mode 100644 index 0000000000000..c6b70443ec8fb --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useGetAppInfo, UseGetAppInfo, UseGetAppInfoProps } from './use_get_app_info'; +import { getAppInfo } from './api'; +import { ServiceNowActionConnector } from './types'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const getAppInfoMock = getAppInfo as jest.Mock; + +const actionTypeId = '.servicenow'; +const applicationInfoData = { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', +}; + +const actionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow ITSM', + isPreconfigured: false, + config: { + apiUrl: 'https://test.service-now.com/', + isLegacy: false, + }, +} as ServiceNowActionConnector; + +describe('useGetAppInfo', () => { + getAppInfoMock.mockResolvedValue(applicationInfoData); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + fetchAppInfo: result.current.fetchAppInfo, + }); + }); + + it('returns the application information', async () => { + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + let res; + + await act(async () => { + res = await result.current.fetchAppInfo(actionConnector); + }); + + expect(res).toEqual(applicationInfoData); + }); + + it('it throws an error when api fails', async () => { + expect.assertions(1); + getAppInfoMock.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + await expect(() => + act(async () => { + await result.current.fetchAppInfo(actionConnector); + }) + ).rejects.toThrow('An error occurred'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx new file mode 100644 index 0000000000000..a211c8dda66b7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { getAppInfo } from './api'; +import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; + +export interface UseGetAppInfoProps { + actionTypeId: string; +} + +export interface UseGetAppInfo { + fetchAppInfo: (connector: ServiceNowActionConnector) => Promise; + isLoading: boolean; +} + +export const useGetAppInfo = ({ actionTypeId }: UseGetAppInfoProps): UseGetAppInfo => { + const [isLoading, setIsLoading] = useState(false); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + const fetchAppInfo = useCallback( + async (connector) => { + try { + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getAppInfo({ + signal: abortCtrl.current.signal, + apiUrl: connector.config.apiUrl, + username: connector.secrets.username, + password: connector.secrets.password, + actionTypeId, + }); + + if (!didCancel.current) { + setIsLoading(false); + } + + return res; + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + } + throw error; + } + }, + [actionTypeId] + ); + + useEffect(() => { + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + setIsLoading(false); + }; + }, []); + + return { + fetchAppInfo, + isLoading, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx index 547346054011b..0a37165bd7f5f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -30,6 +30,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -56,6 +58,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -76,6 +80,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -98,6 +104,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts index 90bab65b83bfd..00262c3265d7a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts @@ -39,29 +39,28 @@ describe('Swimlane API', () => { }); it('returns an error when the response fails', async () => { + expect.assertions(1); const abortCtrl = new AbortController(); - fetchMock.mockResolvedValueOnce({ ok: false, status: 401, json: async () => getApplicationResponse, }); - try { - await getApplication({ + await expect(() => + getApplication({ signal: abortCtrl.signal, apiToken: '', appId: '', url: '', - }); - } catch (e) { - expect(e.message).toContain('Received status:'); - } + }) + ).rejects.toThrow('Received status:'); }); it('returns an error when parsing the json fails', async () => { - const abortCtrl = new AbortController(); + expect.assertions(1); + const abortCtrl = new AbortController(); fetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -70,16 +69,14 @@ describe('Swimlane API', () => { }, }); - try { - await getApplication({ + await expect(() => + getApplication({ signal: abortCtrl.signal, apiToken: '', appId: '', url: '', - }); - } catch (e) { - expect(e.message).toContain('bad'); - } + }) + ).rejects.toThrow('bad'); }); it('it removes unsafe fields', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx index 6740179d786f2..4829156380e94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx @@ -50,6 +50,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -77,6 +79,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -106,6 +110,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -139,6 +145,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -184,6 +192,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -229,6 +239,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -285,6 +297,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx index 11c747125595d..5031b32281258 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx @@ -30,6 +30,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -56,6 +58,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -79,6 +83,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -103,6 +109,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index c041b4e3e1e42..ea40c1ddfb139 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -35,6 +35,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); @@ -62,6 +64,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -92,6 +96,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -123,6 +129,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 091ea1e305e35..5a4d682ff573b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -49,6 +49,8 @@ describe('action_connector_form', () => { dispatch={() => {}} errors={{ name: [] }} actionTypeRegistry={actionTypeRegistry} + setCallbacks={() => {}} + isEdit={false} /> ); const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index f61a0f8f52904..5ee294b6dbd52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -24,6 +24,7 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, ActionTypeModel, + ActionConnectorFieldsSetCallbacks, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useKibana } from '../../../common/lib/kibana'; @@ -89,6 +90,8 @@ interface ActionConnectorProps< serverError?: { body: { message: string; error: string }; }; + setCallbacks: ActionConnectorFieldsSetCallbacks; + isEdit: boolean; } export const ActionConnectorForm = ({ @@ -99,6 +102,8 @@ export const ActionConnectorForm = ({ errors, actionTypeRegistry, consumer, + setCallbacks, + isEdit, }: ActionConnectorProps) => { const { docLinks, @@ -237,6 +242,8 @@ export const ActionConnectorForm = ({ editActionConfig={setActionConfigProperty} editActionSecrets={setActionSecretsProperty} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={isEdit} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 4dcf501fa0023..eda0b99e859a6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -34,11 +34,7 @@ import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; -import { - VIEW_LICENSE_OPTIONS_LINK, - DEFAULT_HIDDEN_ACTION_TYPES, - DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES, -} from '../../../common/constants'; +import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { ActionGroup, AlertActionParam } from '../../../../../alerting/common'; import { useKibana } from '../../../common/lib/kibana'; import { DefaultActionParamsGetter } from '../../lib/get_defaults_for_action_params'; @@ -237,15 +233,9 @@ export const ActionForm = ({ .list() /** * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. - * TODO: Need to decide about ServiceNow SIR connector. * If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not. */ - .filter( - ({ id }) => - actionTypes ?? - (!DEFAULT_HIDDEN_ACTION_TYPES.includes(id) && - !DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES.includes(id)) - ) + .filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id)) .filter((item) => actionTypesIndex[item.id]) .filter((item) => !!item.actionParamsFields) .sort((a, b) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 1a3a186d891cc..16466fc9a210d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -33,6 +33,7 @@ import { IErrorObject, ConnectorAddFlyoutProps, ActionTypeModel, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -121,6 +122,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ }; const [isSaving, setIsSaving] = useState(false); + const [callbacks, setCallbacks] = useState(null); const closeFlyout = useCallback(() => { onClose(); @@ -155,6 +157,8 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ errors={errors.connectorErrors} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={false} /> ); @@ -199,10 +203,21 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ); return; } + setIsSaving(true); + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); + setIsSaving(false); if (savedAction) { + await callbacks?.afterActionConnectorSave?.(savedAction); closeFlyout(); if (reloadConnectors) { await reloadConnectors(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 1e9669d1995dd..7fd6931c936f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -33,6 +33,7 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, IErrorObject, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; @@ -97,6 +98,7 @@ const ConnectorAddModal = ({ secretsErrors: {}, }); + const [callbacks, setCallbacks] = useState(null); const actionTypeModel = actionTypeRegistry.get(actionType.id); useEffect(() => { @@ -189,6 +191,8 @@ const ConnectorAddModal = ({ errors={errors.connectorErrors} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={false} /> {isLoading ? ( <> @@ -230,9 +234,19 @@ const ConnectorAddModal = ({ return; } setIsSaving(true); + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); + setIsSaving(false); if (savedAction) { + await callbacks?.afterActionConnectorSave?.(savedAction); if (postSaveEventHandler) { postSaveEventHandler(savedAction); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 25c8103f0c8dc..206ae0bf5018b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -35,6 +35,7 @@ import { IErrorObject, EditConectorTabs, UserConfiguredActionConnector, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { ConnectorReducer, createConnectorReducer } from './connector_reducer'; import { updateActionConnector, executeAction } from '../../lib/action_connector_api'; @@ -138,6 +139,8 @@ const ConnectorEditFlyout = ({ [testExecutionResult] ); + const [callbacks, setCallbacks] = useState(null); + const closeFlyout = useCallback(() => { setConnector(getConnectorWithoutSecrets()); setHasChanges(false); @@ -236,23 +239,38 @@ const ConnectorEditFlyout = ({ }); }; + const setConnectorWithErrors = () => + setConnector( + getConnectorWithInvalidatedFields( + connector, + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors + ) + ); + const onSaveClicked = async (closeAfterSave: boolean = true) => { if (hasErrors) { - setConnector( - getConnectorWithInvalidatedFields( - connector, - errors.configErrors, - errors.secretsErrors, - errors.connectorBaseErrors - ) - ); + setConnectorWithErrors(); return; } + setIsSaving(true); + + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); setIsSaving(false); + if (savedAction) { setHasChanges(false); + await callbacks?.afterActionConnectorSave?.(savedAction); if (closeAfterSave) { closeFlyout(); } @@ -313,6 +331,8 @@ const ConnectorEditFlyout = ({ }} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={true} /> {isLoading ? ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index c237bbda48658..04f2334f8e8fa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -38,6 +38,7 @@ import { ActionConnectorTableItem, ActionTypeIndex, EditConectorTabs, + UserConfiguredActionConnector, } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; import { useKibana } from '../../../../common/lib/kibana'; @@ -45,6 +46,11 @@ import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import ConnectorEditFlyout from '../../action_connector_form/connector_edit_flyout'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/constants/connectors'; const ActionsConnectorsList: React.FunctionComponent = () => { const { @@ -167,6 +173,14 @@ const ActionsConnectorsList: React.FunctionComponent = () => { const checkEnabledResult = checkActionTypeEnabled( actionTypesIndex && actionTypesIndex[item.actionTypeId] ); + const itemConfig = ( + item as UserConfiguredActionConnector, Record> + ).config; + const showLegacyTooltip = + itemConfig?.isLegacy && + // TODO: Remove when applications are certified + ((ENABLE_NEW_SN_ITSM_CONNECTOR && item.actionTypeId === '.servicenow') || + (ENABLE_NEW_SN_SIR_CONNECTOR && item.actionTypeId === '.servicenow-sir')); const link = ( <> @@ -190,6 +204,23 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} + {showLegacyTooltip && ( + + )} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index c2523dd59821d..9e490945e2261 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -12,5 +12,3 @@ export { builtInGroupByTypes } from './group_by_types'; export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions'; // TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case']; -// Action types included in this array will be hidden only from the alert's action type node list -export const DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES = ['.servicenow-sir']; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index a78d1d52de0bd..8085f9245f4e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -73,6 +73,14 @@ export type ActionTypeRegistryContract< > = PublicMethodsOf>>; export type RuleTypeRegistryContract = PublicMethodsOf>; +export type ActionConnectorFieldsCallbacks = { + beforeActionConnectorSave?: () => Promise; + afterActionConnectorSave?: (connector: ActionConnector) => Promise; +} | null; +export type ActionConnectorFieldsSetCallbacks = React.Dispatch< + React.SetStateAction +>; + export interface ActionConnectorFieldsProps { action: TActionConnector; editActionConfig: (property: string, value: unknown) => void; @@ -80,6 +88,8 @@ export interface ActionConnectorFieldsProps { errors: IErrorObject; readOnly: boolean; consumer?: string; + setCallbacks: ActionConnectorFieldsSetCallbacks; + isEdit: boolean; } export enum AlertFlyoutCloseReason { diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index b0f5f3ea490e5..40a7af18ac906 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -189,6 +189,8 @@ function getServiceNowActionParams(): ServiceNowActionParams { category: null, subcategory: null, externalId: null, + correlation_id: null, + correlation_display: null, }, comments: [], }, diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 87eb866b14fa5..0618d379dc77d 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -34,6 +34,7 @@ const enabledActionTypes = [ '.swimlane', '.server-log', '.servicenow', + '.servicenow-sir', '.jira', '.resilient', '.slack', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 997f36020af8c..ecfd8ef3b8e52 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -42,14 +42,6 @@ export function getAllExternalServiceSimulatorPaths(): string[] { const allPaths = Object.values(ExternalServiceSimulator).map((service) => getExternalServiceSimulatorPath(service) ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); - allPaths.push( - `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident/123` - ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`); - allPaths.push( - `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` - ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/createmeta`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); @@ -76,6 +68,10 @@ export async function getSwimlaneServer(): Promise { return await initSwimlane(); } +export async function getServiceNowServer(): Promise { + return await initServiceNow(); +} + interface FixtureSetupDeps { actions: ActionsPluginSetupContract; features: FeaturesPluginSetup; @@ -127,7 +123,6 @@ export class FixturePlugin implements Plugin, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, - }); - } - ); +import http from 'http'; - router.patch( - { - path: `${path}/api/now/v2/table/incident/{id}`, - options: { - authRequired: false, - }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' }, - }); +export const initPlugin = async () => http.createServer(handler); + +const sendResponse = (response: http.ServerResponse, data: any) => { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(data, null, 4)); +}; + +const handler = async (request: http.IncomingMessage, response: http.ServerResponse) => { + const buffers = []; + let data: Record = {}; + + if (request.method === 'POST') { + for await (const chunk of request) { + buffers.push(chunk); } - ); - router.get( - { - path: `${path}/api/now/v2/table/incident/{id}`, - options: { - authRequired: false, + data = JSON.parse(Buffer.concat(buffers).toString()); + } + + const pathName = request.url!; + + if (pathName.includes('elastic_api/health')) { + return sendResponse(response, { + result: { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { + }); + } + + // Import Set API: Create or update incident + if ( + pathName.includes('x_elas2_inc_int_elastic_incident') || + pathName.includes('x_elas2_sir_int_elastic_si_incident') + ) { + const update = data?.elastic_incident_id != null; + return sendResponse(response, { + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: '/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', sys_id: '123', - number: 'INC01', - sys_created_on: '2020-03-10 12:24:20', - short_description: 'title', - description: 'description', }, - }); - } - ); + ], + }); + } - router.get( - { - path: `${path}/api/now/v2/table/sys_dictionary`, - options: { - authRequired: false, + // Create incident + if ( + pathName === '/api/now/v2/table/incident' || + pathName === '/api/now/v2/table/sn_si_incident' + ) { + return sendResponse(response, { + result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, + }); + } + + // URLs of type /api/now/v2/table/incident/{id} + // GET incident, PATCH incident + if ( + pathName.includes('/api/now/v2/table/incident') || + pathName.includes('/api/now/v2/table/sn_si_incident') + ) { + return sendResponse(response, { + result: { + sys_id: '123', + number: 'INC01', + sys_created_on: '2020-03-10 12:24:20', + sys_updated_on: '2020-03-10 12:24:20', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: [ - { - column_label: 'Close notes', - mandatory: 'false', - max_length: '4000', - element: 'close_notes', - }, - { - column_label: 'Description', - mandatory: 'false', - max_length: '4000', - element: 'description', - }, - { - column_label: 'Short description', - mandatory: 'false', - max_length: '160', - element: 'short_description', - }, - ], - }); - } - ); + }); + } - router.get( - { - path: `${path}/api/now/v2/table/sys_choice`, - options: { - authRequired: false, + // Add multiple observables + if (pathName.includes('/observables/bulk')) { + return sendResponse(response, { + result: [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ], + }); + } + + // Add single observables + if (pathName.includes('/observables')) { + return sendResponse(response, { + result: { + value: '127.0.0.1', + observable_sys_id: '2', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: [ - { - dependent_value: '', - label: '1 - Critical', - value: '1', - }, - { - dependent_value: '', - label: '2 - High', - value: '2', - }, - { - dependent_value: '', - label: '3 - Moderate', - value: '3', - }, - { - dependent_value: '', - label: '4 - Low', - value: '4', - }, - { - dependent_value: '', - label: '5 - Planning', - value: '5', - }, - ], - }); - } - ); -} - -function jsonResponse(res: KibanaResponseFactory, code: number, object?: Record) { - if (object == null) { - return res.custom({ - statusCode: code, - body: '', }); } - return res.custom>({ body: object, statusCode: code }); -} + if (pathName.includes('/api/now/table/sys_dictionary')) { + return sendResponse(response, { + result: [ + { + column_label: 'Close notes', + mandatory: 'false', + max_length: '4000', + element: 'close_notes', + }, + { + column_label: 'Description', + mandatory: 'false', + max_length: '4000', + element: 'description', + }, + { + column_label: 'Short description', + mandatory: 'false', + max_length: '160', + element: 'short_description', + }, + ], + }); + } + + if (pathName.includes('/api/now/table/sys_choice')) { + return sendResponse(response, { + result: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + } + + // Return an 400 error if endpoint is not supported + response.statusCode = 400; + response.setHeader('Content-Type', 'application/json'); + response.end('Not supported endpoint to request servicenow simulator'); +}; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts index afba550908ddc..97cbcbe7a60a6 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts @@ -35,5 +35,5 @@ const handler = (request: http.IncomingMessage, response: http.ServerResponse) = // Return an 400 error if http method is not supported response.statusCode = 400; response.setHeader('Content-Type', 'application/json'); - response.end('Not supported http method to request slack simulator'); + response.end('Not supported http method to request swimlane simulator'); }; 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_itsm.ts similarity index 76% rename from x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts index d6196ee6ce312..fe1ebdf8d28a9 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_itsm.ts @@ -7,24 +7,22 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { - getExternalServiceSimulatorPath, - ExternalServiceSimulator, -} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export -export default function servicenowTest({ getService }: FtrProviderContext) { +export default function serviceNowITSMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const configService = getService('config'); const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, }, secrets: { password: 'elastic', @@ -41,7 +39,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { short_description: 'a title', urgency: '1', category: 'software', - subcategory: 'software', + subcategory: 'os', }, comments: [ { @@ -53,16 +51,37 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }, }; - let servicenowSimulatorURL: string = ''; + describe('ServiceNow ITSM', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; - describe('ServiceNow', () => { - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } ); }); - describe('ServiceNow - Action Creation', () => { + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow ITSM - Action Creation', () => { it('should return 200 when creating a servicenow action successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') @@ -71,7 +90,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, }, secrets: mockServiceNow.secrets, }) @@ -84,7 +103,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', is_missing_secrets: false, config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, }); @@ -99,11 +119,33 @@ export default function servicenowTest({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', is_missing_secrets: false, config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, }); }); + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction.config.isLegacy).to.be(false); + }); + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { await supertest .post('/api/actions/connector') @@ -155,7 +197,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, }, }) .expect(400) @@ -170,10 +212,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); - describe('ServiceNow - Executor', () => { - let simulatedActionId: string; - let proxyServer: httpProxy | undefined; - let proxyHaveBeenCalled = false; + describe('ServiceNow ITSM - Executor', () => { before(async () => { const { body } = await supertest .post('/api/actions/connector') @@ -182,19 +221,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow simulator', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, secrets: mockServiceNow.secrets, }); simulatedActionId = body.id; - - proxyServer = await getHttpProxyServer( - kibanaServer.resolveUrl('/'), - configService.get('kbnTestServer.serverArgs'), - () => { - proxyHaveBeenCalled = true; - } - ); }); describe('Validation', () => { @@ -377,31 +409,81 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); describe('Execution', () => { - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockServiceNow.params, - subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, - comments: [], + // New connectors + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, - }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: simulatedActionId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, - }, + }); + }); + }); + + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }, + }); }); }); @@ -453,12 +535,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); }); - - after(() => { - if (proxyServer) { - proxyServer.close(); - } - }); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts new file mode 100644 index 0000000000000..eee3425b6a61f --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts @@ -0,0 +1,544 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function serviceNowSIRTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + externalId: null, + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'alertID', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + comment: 'first comment', + commentId: '456', + }, + ], + }, + }, + }; + + describe('ServiceNow SIR', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow SIR - Action Creation', () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + }); + + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction.config.isLegacy).to.be(false); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: mockServiceNow.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('ServiceNow SIR - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circumstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ comment: 'boo' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); + }); + }); + + describe('Execution', () => { + // New connectors + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, + }, + }); + }); + }); + + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, + }, + }); + }); + }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index db57af0ba1a98..61bd1bcad34ad 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -25,7 +25,8 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/pagerduty')); loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); - loadTestFile(require.resolve('./builtin_action_types/servicenow')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_sir')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); 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 7367641d71585..f34d7398db0c2 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -6,6 +6,9 @@ */ import { omit } from 'lodash'; +import getPort from 'get-port'; +import http from 'http'; + import expect from '@kbn/expect'; import type { ApiResponse, estypes } from '@elastic/elasticsearch'; import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; @@ -58,6 +61,7 @@ import { User } from './authentication/types'; import { superUser } from './authentication/users'; import { ESCasesConfigureAttributes } from '../../../../plugins/cases/server/services/configure/types'; import { ESCaseAttributes } from '../../../../plugins/cases/server/services/cases/types'; +import { getServiceNowServer } from '../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; function toArray(input: T | T[]): T[] { if (Array.isArray(input)) { @@ -652,13 +656,13 @@ export const getCaseSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => export const createCaseWithConnector = async ({ supertest, configureReq = {}, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth = { user: superUser, space: null }, createCaseReq = getPostCaseRequest(), }: { supertest: SuperTest.SuperTest; - servicenowSimulatorURL: string; + serviceNowSimulatorURL: string; actionsRemover: ActionsRemover; configureReq?: Record; auth?: { user: User; space: string | null }; @@ -671,7 +675,7 @@ export const createCaseWithConnector = async ({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth, }); @@ -1220,3 +1224,17 @@ export const getAlertsAttachedToCase = async ({ return theCase; }; + +export const getServiceNowSimulationServer = async (): Promise<{ + server: http.Server; + url: string; +}> => { + const server = await getServiceNowServer(); + const port = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!server.listening) { + server.listen(port); + } + const url = `http://localhost:${port}`; + + return { server, url }; +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 0ea66d35b63b8..73e8f2ba851fc 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -7,6 +7,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import http from 'http'; + import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -32,11 +34,8 @@ import { getServiceNowConnector, getConnectorMappingsFromES, getCase, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { CaseConnector, CaseStatuses, @@ -55,17 +54,17 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -73,10 +72,14 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should push a case', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -95,18 +98,13 @@ export default ({ getService }: FtrProviderContext): void => { external_title: 'INC01', }); - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); + expect(external_url.includes('nav_to.do?uri=incident.do?sys_id=123')).to.equal(true); }); it('preserves the connector.id after pushing a case', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -121,7 +119,7 @@ export default ({ getService }: FtrProviderContext): void => { it('preserves the external_service.connector_id after updating the connector', async () => { const { postedCase, connector: pushConnector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -135,7 +133,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -175,7 +173,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -222,7 +220,7 @@ export default ({ getService }: FtrProviderContext): void => { it('pushes a comment appropriately', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq }); @@ -241,7 +239,7 @@ export default ({ getService }: FtrProviderContext): void => { closure_type: 'close-by-pushing', }, supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -256,7 +254,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should create the correct user action', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const pushedCase = await pushCase({ @@ -289,7 +287,7 @@ export default ({ getService }: FtrProviderContext): void => { connector_name: connector.name, external_id: '123', external_title: 'INC01', - external_url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + external_url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }); }); @@ -297,7 +295,7 @@ export default ({ getService }: FtrProviderContext): void => { it.skip('should push a collection case but not close it when closure_type: close-by-pushing', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, configureReq: { closure_type: 'close-by-pushing', @@ -337,7 +335,7 @@ export default ({ getService }: FtrProviderContext): void => { it('unhappy path = 409s when case is closed', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); await updateCase({ @@ -367,7 +365,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should push a case that the user has permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, }); @@ -383,7 +381,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), @@ -404,7 +402,7 @@ export default ({ getService }: FtrProviderContext): void => { } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, }); @@ -422,7 +420,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case in a space that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: { user: superUser, space: 'space2' }, }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts index 255a2a4ce28b5..fda2c8d361042 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; @@ -17,13 +18,10 @@ import { deleteConfiguration, getConfigurationRequest, getServiceNowConnector, + getServiceNowSimulationServer, } from '../../../../../common/lib/utils'; import { ObjectRemover as ActionsRemover } from '../../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getCreateConnectorUrl } from '../../../../../../../plugins/cases/common/utils/connectors_api'; // eslint-disable-next-line import/no-default-export @@ -31,15 +29,17 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_all_user_actions', () => { - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); + afterEach(async () => { await deleteCasesByESQuery(es); await deleteComments(es); @@ -48,13 +48,17 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { const { body: connector } = await supertest .post(getCreateConnectorUrl()) .set('kbn-xsrf', 'true') .send({ ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }) .expect(200); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts index ff8f1cff884af..404b63376daa4 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts @@ -5,14 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; - import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, @@ -22,6 +18,7 @@ import { getConfigurationRequest, removeServerGeneratedPropertiesFromSavedObject, getConfigurationOutput, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; @@ -29,27 +26,31 @@ import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_configure', () => { - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should return a configuration with mapping', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); actionsRemover.add('default', connector.id, 'action', 'actions'); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts index fb922f8d10243..c3e737464f19b 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts @@ -109,6 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', + isLegacy: false, }, isPreconfigured: false, isMissingSecrets: false, @@ -118,7 +119,10 @@ export default ({ getService }: FtrProviderContext): void => { id: sir.id, actionTypeId: '.servicenow-sir', name: 'ServiceNow Connector', - config: { apiUrl: 'http://some.non.existent.com' }, + config: { + apiUrl: 'http://some.non.existent.com', + isLegacy: false, + }, isPreconfigured: false, isMissingSecrets: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts index 789b68b19beb6..26eba77dd2576 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts @@ -5,13 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -22,6 +19,7 @@ import { updateConfiguration, getServiceNowConnector, createConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; @@ -29,16 +27,16 @@ import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('patch_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -46,12 +44,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should patch a configuration connector and create mappings', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -107,7 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts index 96ffcf4bc3f5c..077bfc5861322 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -22,22 +19,23 @@ import { createConfiguration, createConnector, getServiceNowConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('post_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -45,12 +43,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should create a configuration with mapping', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); diff --git a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts index 6294400281b92..69d403ea15301 100644 --- a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts @@ -5,6 +5,8 @@ * 2.0. */ +import http from 'http'; + import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -13,11 +15,8 @@ import { pushCase, deleteAllCaseItems, createCaseWithConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { globalRead, noKibanaPrivileges, @@ -31,17 +30,17 @@ import { secOnlyDefaultSpaceAuth } from '../../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -49,12 +48,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + const supertestWithoutAuth = getService('supertestWithoutAuth'); it('should push a case that the user has permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -69,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), }); @@ -95,7 +98,7 @@ export default ({ getService }: FtrProviderContext): void => { } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -112,7 +115,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return a 404 when attempting to access a space', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts index 28b7fe6095507..bfb266e6f6c3a 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts @@ -6,7 +6,7 @@ */ /* eslint-disable @typescript-eslint/naming-convention */ - +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -17,27 +17,24 @@ import { deleteAllCaseItems, createCaseWithConnector, getAuthWithSuperUser, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); const authSpace1 = getAuthWithSuperUser(); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -45,10 +42,14 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should push a case in space1', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: authSpace1, }); @@ -69,18 +70,13 @@ export default ({ getService }: FtrProviderContext): void => { external_title: 'INC01', }); - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); + expect(external_url.includes('nav_to.do?uri=incident.do?sys_id=123')).to.equal(true); }); it('should not push a case in a different space', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts index a142e6470ae93..4da44f08c6236 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts @@ -5,14 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; - import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, @@ -23,6 +19,7 @@ import { removeServerGeneratedPropertiesFromSavedObject, getConfigurationOutput, getAuthWithSuperUser, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { nullUser } from '../../../../common/lib/mock'; @@ -31,28 +28,32 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); describe('get_configure', () => { - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should return a configuration with a mapping from space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); @@ -107,7 +108,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts index 0301fa3a930cb..7b6848d1f301e 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts @@ -109,6 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', + isLegacy: false, }, isPreconfigured: false, isMissingSecrets: false, @@ -118,7 +119,10 @@ export default ({ getService }: FtrProviderContext): void => { id: sir.id, actionTypeId: '.servicenow-sir', name: 'ServiceNow Connector', - config: { apiUrl: 'http://some.non.existent.com' }, + config: { + apiUrl: 'http://some.non.existent.com', + isLegacy: false, + }, isPreconfigured: false, isMissingSecrets: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts index 14d0debe2ac17..ca362d13ae459 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts @@ -5,13 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -24,6 +21,7 @@ import { createConnector, getAuthWithSuperUser, getActionsSpace, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { nullUser } from '../../../../common/lib/mock'; @@ -32,18 +30,18 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); const space = getActionsSpace(authSpace1.space); describe('patch_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -51,12 +49,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should patch a configuration connector and create mappings in space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); @@ -126,7 +128,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts index 7c5035193d465..b815278db5bd8 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -24,6 +21,7 @@ import { getServiceNowConnector, getAuthWithSuperUser, getActionsSpace, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { nullUser } from '../../../../common/lib/mock'; @@ -31,18 +29,18 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); const space = getActionsSpace(authSpace1.space); describe('post_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -50,12 +48,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should create a configuration with a mapping in space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, });