diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 868f6f180cc91..3bc8acead6c13 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -19,7 +19,7 @@ Table of Contents - [Usage](#usage) - [Kibana Actions Configuration](#kibana-actions-configuration) - [Configuration Options](#configuration-options) - - [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-hosts-allow-list) + - [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-allowedhosts) - [Configuration Utilities](#configuration-utilities) - [Action types](#action-types) - [Methods](#methods) @@ -74,13 +74,21 @@ Table of Contents - [`secrets`](#secrets-7) - [`params`](#params-7) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [IBM Resilient](#ibm-resilient) - [`config`](#config-8) - [`secrets`](#secrets-8) - [`params`](#params-8) - - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) + - [licensing](#licensing) + - [plugin location](#plugin-location) + - [documentation](#documentation) + - [tests](#tests) + - [action type config and secrets](#action-type-config-and-secrets) + - [user interface](#user-interface) ## Terminology @@ -103,12 +111,12 @@ Implemented under the [Actions Config](./server/actions_config.ts). Built-In-Actions are configured using the _xpack.actions_ namespoace under _kibana.yml_, and have the following configuration options: -| Namespaced Key | Description | Type | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | -| _xpack.actions._**allowedHosts** | Which _hostnames_ are allowed for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | -| _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | -| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | +| Namespaced Key | Description | Type | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | +| _xpack.actions._**allowedHosts** | Which _hostnames_ are allowed for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | +| _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | +| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | #### Adding Built-in Action Types to allowedHosts @@ -120,14 +128,14 @@ Uniquely, the _PagerDuty Action Type_ has been configured to support the service This module provides a Utilities for interacting with the configuration. -| Method | Arguments | Description | Return Type | -| ------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| isUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and returns `true` if it is allowed. If the configuration says that all URI's are allowed (using an "\*") then it will always return `true`. | Boolean | -| isHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and returns `true` if it is allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will always return `true`. | Boolean | -| isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | -| ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed | -| ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . | -| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | +| Method | Arguments | Description | Return Type | +| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| isUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and returns `true` if it is allowed. If the configuration says that all URI's are allowed (using an "\*") then it will always return `true`. | Boolean | +| isHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and returns `true` if it is allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will always return `true`. | Boolean | +| isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | +| ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed | +| ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . | +| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | ## Action types @@ -442,7 +450,7 @@ The config and params properties are modelled after the [Watcher Index Action](h | index | The Elasticsearch index to index into. | string _(optional)_ | | doc_id | The optional \_id of the document. | string _(optional)_ | | execution_time_field | The field that will store/index the action execution time. | string _(optional)_ | -| refresh | Setting of the refresh policy for the write request. | boolean _(optional)_ | +| refresh | Setting of the refresh policy for the write request. | boolean _(optional)_ | ### `secrets` @@ -450,9 +458,9 @@ This action type has no `secrets` properties. ### `params` -| Property | Description | Type | -| --------- | ---------------------------------------- | ------------------- | -| documents | JSON object that describes the [document](https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index.html#getting-started-batch-processing). | object[] | +| Property | Description | Type | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| documents | JSON object that describes the [document](https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index.html#getting-started-batch-processing). | object[] | --- @@ -529,10 +537,10 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a ### `config` -| Property | Description | Type | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| apiUrl | ServiceNow instance URL. | string | -| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the ServiceNow field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'short_description', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | +| Property | Description | Type | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | +| apiUrl | ServiceNow instance URL. | string | +| incidentConfiguration | Optional property and specific to **Cases only**. If defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the ServiceNow field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'short_description', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object _(optional)_ | ### `secrets` @@ -550,13 +558,17 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a #### `subActionParams (pushToService)` -| Property | Description | Type | -| ----------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| caseId | The case id | string | -| title | The title of the case | string _(optional)_ | -| description | The description of the case | string _(optional)_ | -| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | -| externalId | The id of the incident in ServiceNow . If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +| Property | Description | Type | +| ------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| savedObjectId | The id of the saved object. | string | +| title | The title of the case. | string _(optional)_ | +| description | The description of the case. | string _(optional)_ | +| comment | A comment. | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | +| externalId | The id of the incident in ServiceNow. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +| severity | The name of the severity in ServiceNow. | string _(optional)_ | +| urgency | The name of the urgency in ServiceNow. | string _(optional)_ | +| impact | The name of the impact in ServiceNow. | string _(optional)_ | --- @@ -568,34 +580,47 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla ### `config` -| Property | Description | Type | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| apiUrl | Jira instance URL. | string | -| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object | +| Property | Description | Type | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| apiUrl | Jira instance URL. | string | +| incidentConfiguration | Optional property and specific to **Cases only**. if defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object _(optional)_ | ### `secrets` -| Property | Description | Type | -| -------- | --------------------------------------- | ------ | -| email | email for HTTP Basic authentication | string | -| apiToken | API token for HTTP Basic authentication | string | +| Property | Description | Type | +| -------- | ----------------------------------------------------- | ------ | +| email | email (or username) for HTTP Basic authentication | string | +| apiToken | API token (or password) for HTTP Basic authentication | string | ### `params` -| Property | Description | Type | -| --------------- | ------------------------------------------------------------------------------------ | ------ | -| subAction | The sub action to perform. It can be `pushToService`, `handshake`, and `getIncident` | string | -| subActionParams | The parameters of the sub action | object | +| Property | Description | Type | +| --------------- | ----------------------------------------------------------------------------------------------------------------------- | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `handshake`, `getIncident`, `issueTypes`, and `fieldsByIssueType` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- | +| savedObjectId | The id of the saved object | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +| issueType | The id of the issue type in Jira. | string _(optional)_ | +| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ | +| labels | An array of labels. | string[] _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | + +#### `subActionParams (issueTypes)` + +No parameters for `issueTypes` sub-action. Provide an empty object `{}`. #### `subActionParams (pushToService)` -| Property | Description | Type | -| ----------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- | -| caseId | The case id | string | -| title | The title of the case | string _(optional)_ | -| description | The description of the case | string _(optional)_ | -| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | -| externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +| Property | Description | Type | +| -------- | -------------------------------- | ------ | +| id | The id of the issue type in Jira | string | ## IBM Resilient @@ -603,10 +628,10 @@ ID: `.resilient` ### `config` -| Property | Description | Type | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | -| apiUrl | IBM Resilient instance URL. | string | -| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object | +| Property | Description | Type | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| apiUrl | IBM Resilient instance URL. | string | +| incidentConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object | ### `secrets` @@ -624,13 +649,13 @@ ID: `.resilient` #### `subActionParams (pushToService)` -| Property | Description | Type | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| caseId | The case id | string | -| title | The title of the case | string _(optional)_ | -| description | The description of the case | string _(optional)_ | -| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | -| externalId | The id of the incident in IBM Resilient. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +| Property | Description | Type | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| savedObjectId | The id of the saved object | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| externalId | The id of the incident in IBM Resilient. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | # Command Line Utility @@ -660,30 +685,30 @@ Consider working with the alerting team on early structure /design feedback of n ## licensing -Currently actions are licensed as "basic" if the action only interacts with the stack, eg the server log and es index actions. Other actions are at least "gold" level. +Currently actions are licensed as "basic" if the action only interacts with the stack, eg the server log and es index actions. Other actions are at least "gold" level. ## plugin location -Currently actions that are licensed as "basic" **MUST** be implemented in the actions plugin, other actions can be implemented in any other plugin that pre-reqs the actions plugin. If the new action is generic across the stack, it probably belongs in the actions plugin, but if your action is very specific to a plugin/solution, it might be easiest to implement it in the plugin/solution. Keep in mind that if Kibana is run without the plugin being enabled, any actions defined in that plugin will not run, nor will those actions be available via APIs or UI. +Currently actions that are licensed as "basic" **MUST** be implemented in the actions plugin, other actions can be implemented in any other plugin that pre-reqs the actions plugin. If the new action is generic across the stack, it probably belongs in the actions plugin, but if your action is very specific to a plugin/solution, it might be easiest to implement it in the plugin/solution. Keep in mind that if Kibana is run without the plugin being enabled, any actions defined in that plugin will not run, nor will those actions be available via APIs or UI. -Actions that take URLs or hostnames should check that those values are allowed. The allowed host list utilities are currently internal to the actions plugin, and so such actions will need to be implemented in the actions plugin. Longer-term, we will expose these utilities so they can be used by alerts implemented in other plugins; see [issue #64659](https://github.com/elastic/kibana/issues/64659). +Actions that take URLs or hostnames should check that those values are allowed. The allowed host list utilities are currently internal to the actions plugin, and so such actions will need to be implemented in the actions plugin. Longer-term, we will expose these utilities so they can be used by alerts implemented in other plugins; see [issue #64659](https://github.com/elastic/kibana/issues/64659). ## documentation -You should also create some asciidoc for the new action type. An entry should be made in the action type index - [`docs/user/alerting/action-types.asciidoc`](../../../docs/user/alerting/action-types.asciidoc) which points to a new document for the action type that should be in the directory [`docs/user/alerting/action-types`](../../../docs/user/alerting/action-types). +You should also create some asciidoc for the new action type. An entry should be made in the action type index - [`docs/user/alerting/action-types.asciidoc`](../../../docs/user/alerting/action-types.asciidoc) which points to a new document for the action type that should be in the directory [`docs/user/alerting/action-types`](../../../docs/user/alerting/action-types). ## tests -The action type should have both jest tests and functional tests. For functional tests, if your action interacts with a 3rd party service via HTTP, you may be able to create a simulator for your service, to test with. See the existing functional test servers in the directory [`x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server`](../../test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server) +The action type should have both jest tests and functional tests. For functional tests, if your action interacts with a 3rd party service via HTTP, you may be able to create a simulator for your service, to test with. See the existing functional test servers in the directory [`x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server`](../../test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server) ## action type config and secrets -Action types must define `config` and `secrets` which are used to create connectors. This data should be described with `@kbn/config-schema` object schemas, and you **MUST NOT** use `schema.maybe()` to define properties. +Action types must define `config` and `secrets` which are used to create connectors. This data should be described with `@kbn/config-schema` object schemas, and you **MUST NOT** use `schema.maybe()` to define properties. -This is due to the fact that the structures are persisted in saved objects, which performs partial updates on the persisted data. If a property value is already persisted, but an update either doesn't include the property, or sets it to `undefined`, the persisted value will not be changed. Beyond this being a semantic error in general, it also ends up invalidating the encryption used to save secrets, and will render the secrets will not be able to be unencrypted later. +This is due to the fact that the structures are persisted in saved objects, which performs partial updates on the persisted data. If a property value is already persisted, but an update either doesn't include the property, or sets it to `undefined`, the persisted value will not be changed. Beyond this being a semantic error in general, it also ends up invalidating the encryption used to save secrets, and will render the secrets will not be able to be unencrypted later. -Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `schema.maybe()` except that when passed an `undefined` value, the object returned from the validation will be set to `null`. The resulting type will be `property-type | null`, whereas with `schema.maybe()` it would be `property-type | undefined`. +Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `schema.maybe()` except that when passed an `undefined` value, the object returned from the validation will be set to `null`. The resulting type will be `property-type | null`, whereas with `schema.maybe()` it would be `property-type | undefined`. ## user interface -In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). +In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_shema.ts b/x-pack/plugins/actions/server/builtin_action_types/case/common_schema.ts similarity index 72% rename from x-pack/plugins/actions/server/builtin_action_types/servicenow/case_shema.ts rename to x-pack/plugins/actions/server/builtin_action_types/case/common_schema.ts index 2df8c8156cde8..5a23eb89339e6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_shema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/common_schema.ts @@ -22,13 +22,20 @@ export const IncidentConfigurationSchema = schema.object({ mapping: schema.arrayOf(MapRecordSchema), }); +export const UserSchema = schema.object({ + fullName: schema.nullable(schema.string()), + username: schema.nullable(schema.string()), +}); + export const EntityInformation = { - createdAt: schema.maybe(schema.string()), - createdBy: schema.maybe(schema.any()), + createdAt: schema.nullable(schema.string()), + createdBy: schema.nullable(UserSchema), updatedAt: schema.nullable(schema.string()), - updatedBy: schema.nullable(schema.any()), + updatedBy: schema.nullable(UserSchema), }; +export const EntityInformationSchema = schema.object(EntityInformation); + export const CommentSchema = schema.object({ commentId: schema.string(), comment: schema.string(), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_types.ts b/x-pack/plugins/actions/server/builtin_action_types/case/common_types.ts similarity index 53% rename from x-pack/plugins/actions/server/builtin_action_types/servicenow/case_types.ts rename to x-pack/plugins/actions/server/builtin_action_types/case/common_types.ts index 49b85f9254af9..cca83fb88ca92 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/case_types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/common_types.ts @@ -6,15 +6,11 @@ import { TypeOf } from '@kbn/config-schema'; import { - ExecutorSubActionGetIncidentParamsSchema, - ExecutorSubActionHandshakeParamsSchema, -} from './schema'; -import { IncidentConfigurationSchema, MapRecordSchema } from './case_shema'; -import { - PushToServiceApiParams, - ExternalServiceIncidentResponse, - ExternalServiceParams, -} from './types'; + IncidentConfigurationSchema, + MapRecordSchema, + CommentSchema, + EntityInformationSchema, +} from './common_schema'; export interface CreateCommentRequest { [key: string]: string; @@ -22,6 +18,8 @@ export interface CreateCommentRequest { export type IncidentConfiguration = TypeOf; export type MapRecord = TypeOf; +export type Comment = TypeOf; +export type EntityInformation = TypeOf; export interface ExternalServiceCommentResponse { commentId: string; @@ -29,18 +27,6 @@ export interface ExternalServiceCommentResponse { externalCommentId?: string; } -export type ExecutorSubActionGetIncidentParams = TypeOf< - typeof ExecutorSubActionGetIncidentParamsSchema ->; - -export type ExecutorSubActionHandshakeParams = TypeOf< - typeof ExecutorSubActionHandshakeParamsSchema ->; - -export interface PushToServiceResponse extends ExternalServiceIncidentResponse { - comments?: ExternalServiceCommentResponse[]; -} - export interface PipedField { key: string; value: string; @@ -48,10 +34,10 @@ export interface PipedField { pipes: string[]; } -export interface TransformFieldsArgs { - params: PushToServiceApiParams; +export interface TransformFieldsArgs { + params: P; fields: PipedField[]; - currentIncident?: ExternalServiceParams; + currentIncident?: S; } export interface TransformerArgs { diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts index d895bf386a367..701bbea14fde8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts @@ -51,10 +51,7 @@ export const buildMap = (mapping: MapRecord[]): Map => { }, new Map()); }; -export const mapParams = ( - params: Partial, - mapping: Map -): AnyParams => { +export const mapParams = (params: T, mapping: Map): AnyParams => { return Object.keys(params).reduce((prev: AnyParams, curr: string): AnyParams => { const field = mapping.get(curr); if (field) { @@ -106,7 +103,10 @@ export const createConnectorExecutor = ({ const { comments, externalId, ...restParams } = pushToServiceParams; const mapping = buildMap(config.casesConfiguration.mapping); - const externalCase = mapParams(restParams, mapping); + const externalCase = mapParams( + restParams as ExecutorSubActionPushParams, + mapping + ); data = await api.pushToService({ externalService, diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts index bcfb82077d286..4495c37f758ee 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { api } from '../case/api'; +import { Logger } from '../../../../../../src/core/server'; import { externalServiceMock, mapping, apiParams } from './mocks'; -import { ExternalService } from '../case/types'; +import { ExternalService } from './types'; +import { api } from './api'; +let mockedLogger: jest.Mocked; describe('api', () => { let externalService: jest.Mocked; beforeEach(() => { externalService = externalServiceMock.create(); + jest.clearAllMocks(); }); afterEach(() => { @@ -20,10 +23,15 @@ describe('api', () => { }); describe('pushToService', () => { - describe('create incident', () => { + describe('create incident - cases', () => { test('it creates an incident', async () => { const params = { ...apiParams, externalId: null }; - const res = await api.pushToService({ externalService, mapping, params }); + const res = await api.pushToService({ + externalService, + mapping, + params, + logger: mockedLogger, + }); expect(res).toEqual({ id: 'incident-1', @@ -45,7 +53,12 @@ describe('api', () => { test('it creates an incident without comments', async () => { const params = { ...apiParams, externalId: null, comments: [] }; - const res = await api.pushToService({ externalService, mapping, params }); + const res = await api.pushToService({ + externalService, + mapping, + params, + logger: mockedLogger, + }); expect(res).toEqual({ id: 'incident-1', @@ -57,7 +70,7 @@ describe('api', () => { test('it calls createIncident correctly', async () => { const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping, params }); + await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { @@ -69,9 +82,25 @@ describe('api', () => { expect(externalService.updateIncident).not.toHaveBeenCalled(); }); + test('it calls createIncident correctly without mapping', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + + expect(externalService.createIncident).toHaveBeenCalledWith({ + incident: { + description: 'Incident description', + summary: 'Incident title', + issueType: '10006', + labels: ['kibana', 'elastic'], + priority: 'High', + }, + }); + expect(externalService.updateIncident).not.toHaveBeenCalled(); + }); + test('it calls createComment correctly', async () => { const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, mapping, params }); + await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: 'incident-1', @@ -89,7 +118,6 @@ describe('api', () => { username: 'elastic', }, }, - field: 'comments', }); expect(externalService.createComment).toHaveBeenNthCalledWith(2, { @@ -108,14 +136,59 @@ describe('api', () => { username: 'elastic', }, }, - field: 'comments', + }); + }); + + test('it calls createComment correctly without mapping', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, }); }); }); describe('update incident', () => { test('it updates an incident', async () => { - const res = await api.pushToService({ externalService, mapping, params: apiParams }); + const res = await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(res).toEqual({ id: 'incident-1', @@ -137,7 +210,12 @@ describe('api', () => { test('it updates an incident without comments', async () => { const params = { ...apiParams, comments: [] }; - const res = await api.pushToService({ externalService, mapping, params }); + const res = await api.pushToService({ + externalService, + mapping, + params, + logger: mockedLogger, + }); expect(res).toEqual({ id: 'incident-1', @@ -149,7 +227,7 @@ describe('api', () => { test('it calls updateIncident correctly', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping, params }); + await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', @@ -162,9 +240,26 @@ describe('api', () => { expect(externalService.createIncident).not.toHaveBeenCalled(); }); + test('it calls updateIncident correctly without mapping', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: 'Incident description', + summary: 'Incident title', + issueType: '10006', + labels: ['kibana', 'elastic'], + priority: 'High', + }, + }); + expect(externalService.createIncident).not.toHaveBeenCalled(); + }); + test('it calls createComment correctly', async () => { const params = { ...apiParams }; - await api.pushToService({ externalService, mapping, params }); + await api.pushToService({ externalService, mapping, params, logger: mockedLogger }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { incidentId: 'incident-1', @@ -182,7 +277,6 @@ describe('api', () => { username: 'elastic', }, }, - field: 'comments', }); expect(externalService.createComment).toHaveBeenNthCalledWith(2, { @@ -201,7 +295,87 @@ describe('api', () => { username: 'elastic', }, }, - field: 'comments', + }); + }); + + test('it calls createComment correctly without mapping', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + }); + }); + }); + + describe('issueTypes', () => { + test('it returns the issue types correctly', async () => { + const res = await api.issueTypes({ + externalService, + params: {}, + }); + expect(res).toEqual([ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ]); + }); + }); + + describe('fieldsByIssueType', () => { + test('it returns the fields correctly', async () => { + const res = await api.fieldsByIssueType({ + externalService, + params: { id: '10006' }, + }); + expect(res).toEqual({ + summary: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, }); }); }); @@ -228,7 +402,12 @@ describe('api', () => { actionType: 'overwrite', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -260,7 +439,12 @@ describe('api', () => { actionType: 'nothing', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -291,7 +475,12 @@ describe('api', () => { actionType: 'append', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -324,7 +513,12 @@ describe('api', () => { actionType: 'nothing', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: {}, @@ -352,7 +546,12 @@ describe('api', () => { actionType: 'overwrite', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -382,7 +581,12 @@ describe('api', () => { actionType: 'overwrite', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -414,7 +618,12 @@ describe('api', () => { actionType: 'nothing', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -445,7 +654,12 @@ describe('api', () => { actionType: 'append', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -478,7 +692,12 @@ describe('api', () => { actionType: 'append', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { @@ -509,7 +728,12 @@ describe('api', () => { actionType: 'overwrite', }); - await api.pushToService({ externalService, mapping, params: apiParams }); + await api.pushToService({ + externalService, + mapping, + params: apiParams, + logger: mockedLogger, + }); expect(externalService.createComment).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts index 3db66e5884af4..da47a4bfb839b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts @@ -4,4 +4,179 @@ * you may not use this file except in compliance with the Elastic License. */ -export { api } from '../case/api'; +import { flow } from 'lodash'; +import { + ExternalServiceParams, + PushToServiceApiHandlerArgs, + HandshakeApiHandlerArgs, + GetIncidentApiHandlerArgs, + ExternalServiceApi, + Incident, + GetFieldsByIssueTypeHandlerArgs, + GetIssueTypesHandlerArgs, + PushToServiceApiParams, +} from './types'; + +// TODO: to remove, need to support Case +import { transformers } from '../case/transformers'; +import { TransformFieldsArgs, Comment, EntityInformation } from '../case/common_types'; + +import { PushToServiceResponse } from './types'; +import { prepareFieldsForTransformation } from '../case/utils'; + +const handshakeHandler = async ({ + externalService, + mapping, + params, +}: HandshakeApiHandlerArgs) => {}; + +const getIncidentHandler = async ({ + externalService, + mapping, + params, +}: GetIncidentApiHandlerArgs) => {}; + +const getIssueTypesHandler = async ({ externalService }: GetIssueTypesHandlerArgs) => { + const res = await externalService.getIssueTypes(); + return res; +}; + +const getFieldsByIssueTypeHandler = async ({ + externalService, + params, +}: GetFieldsByIssueTypeHandlerArgs) => { + const { id } = params; + const res = await externalService.getFieldsByIssueType(id); + return res; +}; + +const pushToServiceHandler = async ({ + externalService, + mapping, + params, + logger, +}: PushToServiceApiHandlerArgs): Promise => { + const { externalId, comments } = params; + const updateIncident = externalId ? true : false; + const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; + let currentIncident: ExternalServiceParams | undefined; + let res: PushToServiceResponse; + + if (externalId) { + try { + currentIncident = await externalService.getIncident(externalId); + } catch (ex) { + logger.debug( + `Retrieving Incident by id ${externalId} from Jira failed with exception: ${ex}` + ); + } + } + + let incident: Incident; + // TODO: should be removed later but currently keep it for the Case implementation support + if (mapping) { + const fields = prepareFieldsForTransformation({ + externalCase: params.externalObject, + mapping, + defaultPipes, + }); + + incident = transformFields({ + params, + fields, + currentIncident, + }); + } else { + const { title, description, priority, labels, issueType } = params; + incident = { summary: title, description, priority, labels, issueType }; + } + + if (externalId != null) { + res = await externalService.updateIncident({ + incidentId: externalId, + incident, + }); + } else { + res = await externalService.createIncident({ + incident: { + ...incident, + }, + }); + } + + if (comments && Array.isArray(comments) && comments.length > 0) { + if (mapping && mapping.get('comments')?.actionType === 'nothing') { + return res; + } + + const commentsTransformed = mapping + ? transformComments(comments, ['informationAdded']) + : comments; + + res.comments = []; + for (const currentComment of commentsTransformed) { + const comment = await externalService.createComment({ + incidentId: res.id, + comment: currentComment, + }); + res.comments = [ + ...(res.comments ?? []), + { + commentId: comment.commentId, + pushedDate: comment.pushedDate, + }, + ]; + } + } + + return res; +}; + +export const transformFields = ({ + params, + fields, + currentIncident, +}: TransformFieldsArgs): Incident => { + return fields.reduce((prev, cur) => { + const transform = flow(...cur.pipes.map((p) => transformers[p])); + return { + ...prev, + [cur.key]: transform({ + value: cur.value, + date: params.updatedAt ?? params.createdAt, + user: getEntity(params), + previousValue: currentIncident ? currentIncident[cur.key] : '', + }).value, + }; + }, {} as Incident); +}; + +export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => { + return comments.map((c) => ({ + ...c, + comment: flow(...pipes.map((p) => transformers[p]))({ + value: c.comment, + date: c.updatedAt ?? c.createdAt, + user: getEntity(c), + }).value, + })); +}; + +export const getEntity = (entity: EntityInformation): string => + (entity.updatedBy != null + ? entity.updatedBy.fullName + ? entity.updatedBy.fullName + : entity.updatedBy.username + : entity.createdBy != null + ? entity.createdBy.fullName + ? entity.createdBy.fullName + : entity.createdBy.username + : '') ?? ''; + +export const api: ExternalServiceApi = { + handshake: handshakeHandler, + pushToService: pushToServiceHandler, + getIncident: getIncidentHandler, + issueTypes: getIssueTypesHandler, + fieldsByIssueType: getFieldsByIssueTypeHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index 66be0bad02d7b..d3346557f3684 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -4,33 +4,138 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger } from '../../../../../../src/core/server'; -import { createConnector } from '../case/utils'; -import { ActionType } from '../../types'; +import { curry } from 'lodash'; +import { schema } from '@kbn/config-schema'; -import { api } from './api'; -import { config } from './config'; import { validate } from './validators'; -import { createExternalService } from './service'; -import { JiraSecretConfiguration, JiraPublicConfiguration } from './schema'; +import { + ExternalIncidentServiceConfiguration, + ExternalIncidentServiceSecretConfiguration, + ExecutorParamsSchema, +} from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; +import { createExternalService } from './service'; +import { api } from './api'; +import { + ExecutorParams, + ExecutorSubActionPushParams, + JiraPublicConfigurationType, + JiraSecretConfigurationType, + JiraExecutorResultData, + ExecutorSubActionGetFieldsByIssueTypeParams, + ExecutorSubActionGetIssueTypesParams, +} from './types'; +import * as i18n from './translations'; +import { Logger } from '../../../../../../src/core/server'; -export function getActionType({ - logger, - configurationUtilities, -}: { +// TODO: to remove, need to support Case +import { buildMap, mapParams } from '../case/utils'; + +interface GetActionTypeParams { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; -}): ActionType { - return createConnector({ - api, - config, - validate, - createExternalService, - validationSchema: { - config: JiraPublicConfiguration, - secrets: JiraSecretConfiguration, +} + +const supportedSubActions: string[] = ['pushToService', 'issueTypes', 'fieldsByIssueType']; + +// action type definition +export function getActionType( + params: GetActionTypeParams +): ActionType< + JiraPublicConfigurationType, + JiraSecretConfigurationType, + ExecutorParams, + JiraExecutorResultData | {} +> { + const { logger, configurationUtilities } = params; + return { + id: '.jira', + minimumLicenseRequired: 'gold', + name: i18n.NAME, + validate: { + config: schema.object(ExternalIncidentServiceConfiguration, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchema, + }, + executor: curry(executor)({ logger }), + }; +} + +// action executor +async function executor( + { logger }: { logger: Logger }, + execOptions: ActionTypeExecutorOptions< + JiraPublicConfigurationType, + JiraSecretConfigurationType, + ExecutorParams + > +): Promise> { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params as ExecutorParams; + let data: JiraExecutorResultData | null = null; + + const externalService = createExternalService( + { + config, + secrets, }, logger, - })({ configurationUtilities }); + execOptions.proxySettings + ); + + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (subAction === 'pushToService') { + const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; + + const { comments, externalId, ...restParams } = pushToServiceParams; + const incidentConfiguration = config.incidentConfiguration; + const mapping = incidentConfiguration ? buildMap(incidentConfiguration.mapping) : null; + const externalObject = + config.incidentConfiguration && mapping + ? mapParams(restParams as ExecutorSubActionPushParams, mapping) + : {}; + + data = await api.pushToService({ + externalService, + mapping, + params: { ...pushToServiceParams, externalObject }, + logger, + }); + + logger.debug(`response push to service for incident id: ${data.id}`); + } + + if (subAction === 'issueTypes') { + const getIssueTypesParams = subActionParams as ExecutorSubActionGetIssueTypesParams; + data = await api.issueTypes({ + externalService, + params: getIssueTypesParams, + }); + } + + if (subAction === 'fieldsByIssueType') { + const getFieldsByIssueTypeParams = subActionParams as ExecutorSubActionGetFieldsByIssueTypeParams; + data = await api.fieldsByIssueType({ + externalService, + params: getFieldsByIssueTypeParams, + }); + } + + return { status: 'ok', data: data ?? {}, actionId }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts index 709d490a5227f..e7841996fedef 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ExternalService, - PushToServiceApiParams, - ExecutorSubActionPushParams, - MapRecord, -} from '../case/types'; +import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; + +import { MapRecord } from '../case/common_types'; const createMock = (): jest.Mocked => { const service = { @@ -40,6 +37,30 @@ const createMock = (): jest.Mocked => { }) ), createComment: jest.fn(), + findIncidents: jest.fn(), + getCapabilities: jest.fn(), + getIssueTypes: jest.fn().mockImplementation(() => [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ]), + getFieldsByIssueType: jest.fn().mockImplementation(() => ({ + summary: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + })), }; service.createComment.mockImplementationOnce(() => @@ -96,6 +117,9 @@ const executorParams: ExecutorSubActionPushParams = { updatedBy: { fullName: 'Elastic User', username: 'elastic' }, title: 'Incident title', description: 'Incident description', + labels: ['kibana', 'elastic'], + priority: 'High', + issueType: '10006', comments: [ { commentId: 'case-comment-1', @@ -118,7 +142,7 @@ const executorParams: ExecutorSubActionPushParams = { const apiParams: PushToServiceApiParams = { ...executorParams, - externalCase: { summary: 'Incident title', description: 'Incident description' }, + externalObject: { summary: 'Incident title', description: 'Incident description' }, }; export { externalServiceMock, mapping, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index 9c831e75d91c1..07c8e22812b27 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -5,18 +5,85 @@ */ import { schema } from '@kbn/config-schema'; -import { ExternalIncidentServiceConfiguration } from '../case/schema'; +import { + CommentSchema, + EntityInformation, + IncidentConfigurationSchema, +} from '../case/common_schema'; -export const JiraPublicConfiguration = { +export const ExternalIncidentServiceConfiguration = { + apiUrl: schema.string(), projectKey: schema.string(), - ...ExternalIncidentServiceConfiguration, + // TODO: to remove - set it optional for the current stage to support Case Jira implementation + incidentConfiguration: schema.nullable(IncidentConfigurationSchema), + isCaseOwned: schema.nullable(schema.boolean()), }; -export const JiraPublicConfigurationSchema = schema.object(JiraPublicConfiguration); +export const ExternalIncidentServiceConfigurationSchema = schema.object( + ExternalIncidentServiceConfiguration +); -export const JiraSecretConfiguration = { +export const ExternalIncidentServiceSecretConfiguration = { email: schema.string(), apiToken: schema.string(), }; -export const JiraSecretConfigurationSchema = schema.object(JiraSecretConfiguration); +export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( + ExternalIncidentServiceSecretConfiguration +); + +export const ExecutorSubActionSchema = schema.oneOf([ + schema.literal('getIncident'), + schema.literal('pushToService'), + schema.literal('handshake'), + schema.literal('issueTypes'), + schema.literal('fieldsByIssueType'), +]); + +export const ExecutorSubActionPushParamsSchema = schema.object({ + savedObjectId: schema.string(), + title: schema.string(), + description: schema.nullable(schema.string()), + externalId: schema.nullable(schema.string()), + issueType: schema.nullable(schema.string()), + priority: schema.nullable(schema.string()), + labels: schema.nullable(schema.arrayOf(schema.string())), + // TODO: modify later to string[] - need for support Case schema + comments: schema.nullable(schema.arrayOf(CommentSchema)), + ...EntityInformation, +}); + +export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ + externalId: schema.string(), +}); + +// Reserved for future implementation +export const ExecutorSubActionHandshakeParamsSchema = schema.object({}); +export const ExecutorSubActionGetCapabilitiesParamsSchema = schema.object({}); +export const ExecutorSubActionGetIssueTypesParamsSchema = schema.object({}); +export const ExecutorSubActionGetFieldsByIssueTypeParamsSchema = schema.object({ + id: schema.string(), +}); + +export const ExecutorParamsSchema = schema.oneOf([ + schema.object({ + subAction: schema.literal('getIncident'), + subActionParams: ExecutorSubActionGetIncidentParamsSchema, + }), + schema.object({ + subAction: schema.literal('handshake'), + subActionParams: ExecutorSubActionHandshakeParamsSchema, + }), + schema.object({ + subAction: schema.literal('pushToService'), + subActionParams: ExecutorSubActionPushParamsSchema, + }), + schema.object({ + subAction: schema.literal('issueTypes'), + subActionParams: ExecutorSubActionGetIssueTypesParamsSchema, + }), + schema.object({ + subAction: schema.literal('fieldsByIssueType'), + subActionParams: ExecutorSubActionGetFieldsByIssueTypeParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 547595b4c183f..2439c507c3328 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -8,11 +8,15 @@ import axios from 'axios'; import { createExternalService } from './service'; import * as utils from '../lib/axios_utils'; -import { ExternalService } from '../case/types'; +import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked; +interface ResponseError extends Error { + response?: { data: { errors: Record } }; +} + jest.mock('axios'); jest.mock('../lib/axios_utils', () => { const originalUtils = jest.requireActual('../lib/axios_utils'); @@ -25,6 +29,72 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; +const issueTypesResponse = { + data: { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }, + ], + }, +}; + +const fieldsResponse = { + data: { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + fields: { + summary: { fieldId: 'summary' }, + priority: { + fieldId: 'priority', + allowedValues: [ + { + name: 'Highest', + id: '1', + }, + { + name: 'High', + id: '2', + }, + { + name: 'Medium', + id: '3', + }, + { + name: 'Low', + id: '4', + }, + { + name: 'Lowest', + id: '5', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, + }, + }, + ], + }, + ], + }, +}; + describe('Jira service', () => { let service: ExternalService; @@ -116,19 +186,24 @@ describe('Jira service', () => { test('it should throw an error', async () => { requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { summary: 'Required field' } } }; + throw error; }); expect(service.getIncident('1')).rejects.toThrow( - 'Unable to get incident with id 1. Error: An error has occurred' + '[Action][Jira]: Unable to get incident with id 1. Error: An error has occurred Reason: Required field' ); }); }); describe('createIncident', () => { test('it creates the incident correctly', async () => { - // The response from Jira when creating an issue contains only the key and the id. - // The service makes two calls when creating an issue. One to create and one to get - // the created incident with all the necessary fields. + /* The response from Jira when creating an issue contains only the key and the id. + The function makes the following calls when creating an issue: + 1. Get issueTypes to set a default ONLY when incident.issueType is missing + 2. Create the issue. + 3. Get the created issue with all the necessary fields. + */ requestMock.mockImplementationOnce(() => ({ data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, })); @@ -138,7 +213,13 @@ describe('Jira service', () => { })); const res = await service.createIncident({ - incident: { summary: 'title', description: 'desc' }, + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + }, }); expect(res).toEqual({ @@ -149,6 +230,68 @@ describe('Jira service', () => { }); }); + test('it creates the incident correctly without issue type', async () => { + /* The response from Jira when creating an issue contains only the key and the id. + The function makes the following calls when creating an issue: + 1. Get issueTypes to set a default ONLY when incident.issueType is missing + 2. Create the issue. + 3. Get the created issue with all the necessary fields. + */ + // getIssueType mocks + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + // getIssueType mocks + requestMock.mockImplementationOnce(() => issueTypesResponse); + + requestMock.mockImplementationOnce(() => ({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + })); + + const res = await service.createIncident({ + incident: { + summary: 'title', + description: 'desc', + labels: [], + priority: 'High', + issueType: null, + }, + }); + + expect(res).toEqual({ + title: 'CK-1', + id: '1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', + logger, + method: 'post', + data: { + fields: { + summary: 'title', + description: 'desc', + project: { key: 'CK' }, + issuetype: { id: '10006' }, + labels: [], + priority: { name: 'High' }, + }, + }, + }); + }); + test('it should call request with correct arguments', async () => { requestMock.mockImplementation(() => ({ data: { @@ -159,7 +302,13 @@ describe('Jira service', () => { })); await service.createIncident({ - incident: { summary: 'title', description: 'desc' }, + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + }, }); expect(requestMock).toHaveBeenCalledWith({ @@ -172,7 +321,9 @@ describe('Jira service', () => { summary: 'title', description: 'desc', project: { key: 'CK' }, - issuetype: { name: 'Task' }, + issuetype: { id: '10006' }, + labels: [], + priority: { name: 'High' }, }, }, }); @@ -180,14 +331,24 @@ describe('Jira service', () => { test('it should throw an error', async () => { requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { summary: 'Required field' } } }; + throw error; }); expect( service.createIncident({ - incident: { summary: 'title', description: 'desc' }, + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + }, }) - ).rejects.toThrow('[Action][Jira]: Unable to create incident. Error: An error has occurred'); + ).rejects.toThrow( + '[Action][Jira]: Unable to create incident. Error: An error has occurred. Reason: Required field' + ); }); }); @@ -203,7 +364,13 @@ describe('Jira service', () => { const res = await service.updateIncident({ incidentId: '1', - incident: { summary: 'title', description: 'desc' }, + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + }, }); expect(res).toEqual({ @@ -225,7 +392,13 @@ describe('Jira service', () => { await service.updateIncident({ incidentId: '1', - incident: { summary: 'title', description: 'desc' }, + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + }, }); expect(requestMock).toHaveBeenCalledWith({ @@ -233,22 +406,39 @@ describe('Jira service', () => { logger, method: 'put', url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', - data: { fields: { summary: 'title', description: 'desc' } }, + data: { + fields: { + summary: 'title', + description: 'desc', + labels: [], + priority: { name: 'High' }, + issuetype: { id: '10006' }, + project: { key: 'CK' }, + }, + }, }); }); test('it should throw an error', async () => { requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { summary: 'Required field' } } }; + throw error; }); expect( service.updateIncident({ incidentId: '1', - incident: { summary: 'title', description: 'desc' }, + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + }, }) ).rejects.toThrow( - '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred' + '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred. Reason: Required field' ); }); }); @@ -265,8 +455,14 @@ describe('Jira service', () => { const res = await service.createComment({ incidentId: '1', - comment: { comment: 'comment', commentId: 'comment-1' }, - field: 'comments', + comment: { + comment: 'comment', + commentId: 'comment-1', + createdBy: null, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, }); expect(res).toEqual({ @@ -287,8 +483,14 @@ describe('Jira service', () => { await service.createComment({ incidentId: '1', - comment: { comment: 'comment', commentId: 'comment-1' }, - field: 'my_field', + comment: { + comment: 'comment', + commentId: 'comment-1', + createdBy: null, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, }); expect(requestMock).toHaveBeenCalledWith({ @@ -302,18 +504,416 @@ describe('Jira service', () => { test('it should throw an error', async () => { requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { summary: 'Required field' } } }; + throw error; }); expect( service.createComment({ incidentId: '1', - comment: { comment: 'comment', commentId: 'comment-1' }, - field: 'comments', + comment: { + comment: 'comment', + commentId: 'comment-1', + createdBy: null, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, }) ).rejects.toThrow( - '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred' + '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred. Reason: Required field' + ); + }); + }); + + describe('getCapabilities', () => { + test('it should return the capabilities', async () => { + requestMock.mockImplementation(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + const res = await service.getCapabilities(); + expect(res).toEqual({ + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + await service.getCapabilities(); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + method: 'get', + url: 'https://siem-kibana.atlassian.net/rest/capabilities', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { capabilities: 'Could not get capabilities' } } }; + throw error; + }); + + expect(service.getCapabilities()).rejects.toThrow( + '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: Could not get capabilities' ); }); }); + + describe('getIssueTypes', () => { + describe('Old API', () => { + test('it should return the issue types', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + requestMock.mockImplementationOnce(() => issueTypesResponse); + + const res = await service.getIssueTypes(); + + expect(res).toEqual([ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ]); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + requestMock.mockImplementationOnce(() => issueTypesResponse); + + await service.getIssueTypes(); + + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + url: + 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&expand=projects.issuetypes.fields', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; + throw error; + }); + + expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' + ); + }); + }); + describe('New API', () => { + test('it should return the issue types', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, + }, + })); + + const res = await service.getIssueTypes(); + + expect(res).toEqual([ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ]); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, + }, + })); + + await service.getIssueTypes(); + + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + })); + + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; + throw error; + }); + + expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' + ); + }); + }); + }); + + describe('getFieldsByIssueType', () => { + describe('Old API', () => { + test('it should return the fields', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + requestMock.mockImplementationOnce(() => fieldsResponse); + + const res = await service.getFieldsByIssueType('10006'); + + expect(res).toEqual({ + priority: { + allowedValues: [ + { id: '1', name: 'Highest' }, + { id: '2', name: 'High' }, + { id: '3', name: 'Medium' }, + { id: '4', name: 'Low' }, + { id: '5', name: 'Lowest' }, + ], + defaultValue: { id: '3', name: 'Medium' }, + }, + summary: { allowedValues: [], defaultValue: {} }, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + requestMock.mockImplementationOnce(() => fieldsResponse); + + await service.getFieldsByIssueType('10006'); + + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + url: + 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&issuetypeIds=10006&expand=projects.issuetypes.fields', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + })); + + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { fields: 'Could not get fields' } } }; + throw error; + }); + + expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get fields' + ); + }); + }); + + describe('New API', () => { + test('it should return the fields', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { + values: [ + { fieldId: 'summary' }, + { + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, + ], + }, + })); + + const res = await service.getFieldsByIssueType('10006'); + + expect(res).toEqual({ + priority: { + allowedValues: [{ id: '3', name: 'Medium' }], + defaultValue: { id: '3', name: 'Medium' }, + }, + summary: { allowedValues: [], defaultValue: {} }, + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { + values: [ + { fieldId: 'summary' }, + { + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { + name: 'Medium', + id: '3', + }, + }, + ], + }, + })); + + await service.getFieldsByIssueType('10006'); + + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes/10006', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementationOnce(() => ({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + })); + + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } }; + throw error; + }); + + expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get issue types' + ); + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index aec73cfb375ed..84b6e70d2a100 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -6,14 +6,20 @@ import axios from 'axios'; -import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; import { Logger } from '../../../../../../src/core/server'; import { + ExternalServiceCredentials, + ExternalService, + CreateIncidentParams, + UpdateIncidentParams, JiraPublicConfigurationType, JiraSecretConfigurationType, - CreateIncidentRequest, - UpdateIncidentRequest, - CreateCommentRequest, + Fields, + CreateCommentParams, + Incident, + ResponseError, + ExternalServiceCommentResponse, + ExternalServiceIncidentResponse, } from './types'; import * as i18n from './translations'; @@ -22,11 +28,12 @@ import { ProxySettings } from '../../types'; const VERSION = '2'; const BASE_URL = `rest/api/${VERSION}`; -const INCIDENT_URL = `issue`; -const COMMENT_URL = `comment`; +const CAPABILITIES_URL = `rest/capabilities`; const VIEW_INCIDENT_URL = `browse`; +const createMetaCapabilities = ['list-project-issuetypes', 'list-issuetype-fields']; + export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, @@ -39,8 +46,13 @@ export const createExternalService = ( throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${BASE_URL}/${INCIDENT_URL}`; - const commentUrl = `${incidentUrl}/{issueId}/${COMMENT_URL}`; + const incidentUrl = `${url}/${BASE_URL}/issue`; + const capabilitiesUrl = `${url}/${CAPABILITIES_URL}`; + const commentUrl = `${incidentUrl}/{issueId}/comment`; + const getIssueTypesOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`; + const getIssueTypeFieldsOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; + const getIssueTypesUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; + const getIssueTypeFieldsUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; const axiosInstance = axios.create({ auth: { username: email, password: apiToken }, }); @@ -52,6 +64,60 @@ export const createExternalService = ( const getCommentsURL = (issueId: string) => { return commentUrl.replace('{issueId}', issueId); }; + const createGetIssueTypeFieldsUrl = (uri: string, issueTypeId: string) => { + return uri.replace('{issueTypeId}', issueTypeId); + }; + + const createFields = (key: string, incident: Incident): Fields => { + let fields: Fields = { + summary: incident.summary, + project: { key }, + }; + + if (incident.issueType) { + fields = { ...fields, issuetype: { id: incident.issueType } }; + } + + if (incident.description) { + fields = { ...fields, description: incident.description }; + } + + if (incident.labels) { + fields = { ...fields, labels: incident.labels }; + } + + if (incident.priority) { + fields = { ...fields, priority: { name: incident.priority } }; + } + + return fields; + }; + + const createErrorMessage = (errors: ResponseError) => { + return Object.entries(errors).reduce((errorMessage, [, value]) => { + const msg = errorMessage.length > 0 ? `${errorMessage} ${value}` : value; + return msg; + }, ''); + }; + + const hasSupportForNewAPI = (capabilities: { capabilities?: {} }) => + createMetaCapabilities.every((c) => Object.keys(capabilities?.capabilities ?? {}).includes(c)); + + const normalizeIssueTypes = (issueTypes: Array<{ id: string; name: string }>) => + issueTypes.map((type) => ({ id: type.id, name: type.name })); + + const normalizeFields = (fields: { + [key: string]: { allowedValues?: Array<{}>; defaultValue?: {} }; + }) => + Object.keys(fields ?? {}).reduce((fieldsAcc, fieldKey) => { + return { + ...fieldsAcc, + [fieldKey]: { + allowedValues: fields[fieldKey]?.allowedValues ?? [], + defaultValue: fields[fieldKey]?.defaultValue ?? {}, + }, + }; + }, {}); const getIncident = async (id: string) => { try { @@ -67,23 +133,46 @@ export const createExternalService = ( return { ...rest, ...fields }; } catch (error) { throw new Error( - getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + getErrorMessage( + i18n.NAME, + `Unable to get incident with id ${id}. Error: ${ + error.message + } Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}` + ) ); } }; - const createIncident = async ({ incident }: ExternalServiceParams) => { - // The response from Jira when creating an issue contains only the key and the id. - // The function makes two calls when creating an issue. One to create the issue and one to get - // the created issue with all the necessary fields. + const createIncident = async ({ + incident, + }: CreateIncidentParams): Promise => { + /* The response from Jira when creating an issue contains only the key and the id. + The function makes the following calls when creating an issue: + 1. Get issueTypes to set a default ONLY when incident.issueType is missing + 2. Create the issue. + 3. Get the created issue with all the necessary fields. + */ + + let issueType = incident.issueType; + + if (!incident.issueType) { + const issueTypes = await getIssueTypes(); + issueType = issueTypes[0]?.id ?? ''; + } + + const fields = createFields(projectKey, { + ...incident, + issueType, + }); + try { - const res = await request({ + const res = await request({ axios: axiosInstance, url: `${incidentUrl}`, logger, method: 'post', data: { - fields: { ...incident, project: { key: projectKey }, issuetype: { name: 'Task' } }, + fields, }, proxySettings, }); @@ -98,23 +187,38 @@ export const createExternalService = ( }; } catch (error) { throw new Error( - getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + getErrorMessage( + i18n.NAME, + `Unable to create incident. Error: ${error.message}. Reason: ${createErrorMessage( + error.response?.data?.errors ?? {} + )}` + ) ); } }; - const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + const updateIncident = async ({ + incidentId, + incident, + }: UpdateIncidentParams): Promise => { + const incidentWithoutNullValues = Object.entries(incident).reduce( + (obj, [key, value]) => (value != null ? { ...obj, [key]: value } : obj), + {} as Incident + ); + + const fields = createFields(projectKey, incidentWithoutNullValues); + try { - await request({ + await request({ axios: axiosInstance, method: 'put', url: `${incidentUrl}/${incidentId}`, logger, - data: { fields: { ...incident } }, + data: { fields }, proxySettings, }); - const updatedIncident = await getIncident(incidentId); + const updatedIncident = await getIncident(incidentId as string); return { title: updatedIncident.key, @@ -126,15 +230,20 @@ export const createExternalService = ( throw new Error( getErrorMessage( i18n.NAME, - `Unable to update incident with id ${incidentId}. Error: ${error.message}` + `Unable to update incident with id ${incidentId}. Error: ${ + error.message + }. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}` ) ); } }; - const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + const createComment = async ({ + incidentId, + comment, + }: CreateCommentParams): Promise => { try { - const res = await request({ + const res = await request({ axios: axiosInstance, method: 'post', url: getCommentsURL(incidentId), @@ -152,7 +261,118 @@ export const createExternalService = ( throw new Error( getErrorMessage( i18n.NAME, - `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + `Unable to create comment at incident with id ${incidentId}. Error: ${ + error.message + }. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}` + ) + ); + } + }; + + const getCapabilities = async () => { + try { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: capabilitiesUrl, + logger, + proxySettings, + }); + + return { ...res.data }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to get capabilities. Error: ${error.message}. Reason: ${createErrorMessage( + error.response?.data?.errors ?? {} + )}` + ) + ); + } + }; + + const getIssueTypes = async () => { + const capabilitiesResponse = await getCapabilities(); + const supportsNewAPI = hasSupportForNewAPI(capabilitiesResponse); + + try { + if (!supportsNewAPI) { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: getIssueTypesOldAPIURL, + logger, + proxySettings, + }); + + const issueTypes = res.data.projects[0]?.issuetypes ?? []; + return normalizeIssueTypes(issueTypes); + } else { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: getIssueTypesUrl, + logger, + proxySettings, + }); + + const issueTypes = res.data.values; + return normalizeIssueTypes(issueTypes); + } + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to get issue types. Error: ${error.message}. Reason: ${createErrorMessage( + error.response?.data?.errors ?? {} + )}` + ) + ); + } + }; + + const getFieldsByIssueType = async (issueTypeId: string) => { + const capabilitiesResponse = await getCapabilities(); + const supportsNewAPI = hasSupportForNewAPI(capabilitiesResponse); + + try { + if (!supportsNewAPI) { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsOldAPIURL, issueTypeId), + logger, + proxySettings, + }); + + const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; + return normalizeFields(fields); + } else { + const res = await request({ + axios: axiosInstance, + method: 'get', + url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsUrl, issueTypeId), + logger, + proxySettings, + }); + + const fields = res.data.values.reduce( + (acc: { [x: string]: {} }, value: { fieldId: string }) => ({ + ...acc, + [value.fieldId]: { ...value }, + }), + {} + ); + return normalizeFields(fields); + } + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to get fields. Error: ${error.message}. Reason: ${createErrorMessage( + error.response?.data?.errors ?? {} + )}` ) ); } @@ -163,5 +383,8 @@ export const createExternalService = ( createIncident, updateIncident, createComment, + getCapabilities, + getIssueTypes, + getFieldsByIssueType, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts index dae0d75952e11..0e71de813eb5d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts @@ -9,3 +9,19 @@ import { i18n } from '@kbn/i18n'; export const NAME = i18n.translate('xpack.actions.builtin.case.jiraTitle', { defaultMessage: 'Jira', }); + +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.jira.configuration.apiAllowedHostsError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); + +// TODO: remove when Case mappings will be removed +export const MAPPING_EMPTY = i18n.translate( + 'xpack.actions.builtin.jira.configuration.emptyMapping', + { + defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty', + } +); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts index 8d9c6b92abb3b..5e97f5309f8ee 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -4,29 +4,169 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { TypeOf } from '@kbn/config-schema'; -import { JiraPublicConfigurationSchema, JiraSecretConfigurationSchema } from './schema'; +import { + ExternalIncidentServiceConfigurationSchema, + ExternalIncidentServiceSecretConfigurationSchema, + ExecutorParamsSchema, + ExecutorSubActionPushParamsSchema, + ExecutorSubActionGetIncidentParamsSchema, + ExecutorSubActionHandshakeParamsSchema, + ExecutorSubActionGetCapabilitiesParamsSchema, + ExecutorSubActionGetIssueTypesParamsSchema, + ExecutorSubActionGetFieldsByIssueTypeParamsSchema, +} from './schema'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { IncidentConfigurationSchema } from '../case/common_schema'; +import { Comment } from '../case/common_types'; +import { Logger } from '../../../../../../src/core/server'; + +export type JiraPublicConfigurationType = TypeOf; +export type JiraSecretConfigurationType = TypeOf< + typeof ExternalIncidentServiceSecretConfigurationSchema +>; + +export type ExecutorParams = TypeOf; +export type ExecutorSubActionPushParams = TypeOf; + +export type IncidentConfiguration = TypeOf; + +export interface ExternalServiceCredentials { + config: Record; + secrets: Record; +} + +export interface ExternalServiceValidation { + config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void; + secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} + +export type ExternalServiceParams = Record; + +export type Incident = Pick< + ExecutorSubActionPushParams, + 'description' | 'priority' | 'labels' | 'issueType' +> & { summary: string }; + +export interface CreateIncidentParams { + incident: Incident; +} + +export interface UpdateIncidentParams { + incidentId: string; + incident: Incident; +} + +export interface CreateCommentParams { + incidentId: string; + comment: Comment; +} -export type JiraPublicConfigurationType = TypeOf; -export type JiraSecretConfigurationType = TypeOf; +export type GetIssueTypesResponse = Array<{ id: string; name: string }>; +export type GetFieldsByIssueTypeResponse = Record< + string, + { allowedValues: Array<{}>; defaultValue: {} } +>; -interface CreateIncidentBasicRequestArgs { - summary: string; - description: string; +export interface ExternalService { + getIncident: (id: string) => Promise; + createIncident: (params: CreateIncidentParams) => Promise; + updateIncident: (params: UpdateIncidentParams) => Promise; + createComment: (params: CreateCommentParams) => Promise; + getCapabilities: () => Promise; + getIssueTypes: () => Promise; + getFieldsByIssueType: (issueTypeId: string) => Promise; } -interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs { - project: { key: string }; - issuetype: { name: string }; + +export interface PushToServiceApiParams extends ExecutorSubActionPushParams { + externalObject: Record; +} + +export type ExecutorSubActionGetIncidentParams = TypeOf< + typeof ExecutorSubActionGetIncidentParamsSchema +>; + +export type ExecutorSubActionHandshakeParams = TypeOf< + typeof ExecutorSubActionHandshakeParamsSchema +>; + +export type ExecutorSubActionGetCapabilitiesParams = TypeOf< + typeof ExecutorSubActionGetCapabilitiesParamsSchema +>; + +export type ExecutorSubActionGetIssueTypesParams = TypeOf< + typeof ExecutorSubActionGetIssueTypesParamsSchema +>; + +export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf< + typeof ExecutorSubActionGetFieldsByIssueTypeParamsSchema +>; + +export interface ExternalServiceApiHandlerArgs { + externalService: ExternalService; + mapping: Map | null; +} + +export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: PushToServiceApiParams; + logger: Logger; } -export interface CreateIncidentRequest { - fields: CreateIncidentRequestArgs; +export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionGetIncidentParams; } -export interface UpdateIncidentRequest { - fields: Partial; +export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionHandshakeParams; } -export interface CreateCommentRequest { - body: string; +export interface GetIssueTypesHandlerArgs { + externalService: ExternalService; + params: ExecutorSubActionGetIssueTypesParams; +} + +export interface GetFieldsByIssueTypeHandlerArgs { + externalService: ExternalService; + params: ExecutorSubActionGetFieldsByIssueTypeParams; +} + +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} + +export interface ExternalServiceApi { + handshake: (args: HandshakeApiHandlerArgs) => Promise; + pushToService: (args: PushToServiceApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; + issueTypes: (args: GetIssueTypesHandlerArgs) => Promise; + fieldsByIssueType: ( + args: GetFieldsByIssueTypeHandlerArgs + ) => Promise; +} + +export type JiraExecutorResultData = + | PushToServiceResponse + | GetIssueTypesResponse + | GetFieldsByIssueTypeResponse; + +export interface Fields { + [key: string]: string | string[] | { name: string } | { key: string } | { id: string }; +} +export interface ResponseError { + [k: string]: string; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts index 7226071392bc6..58a3e27247fae 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts @@ -4,8 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import { validateCommonConfig, validateCommonSecrets } from '../case/validators'; -import { ExternalServiceValidation } from '../case/types'; +import { isEmpty } from 'lodash'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { + JiraPublicConfigurationType, + JiraSecretConfigurationType, + ExternalServiceValidation, +} from './types'; + +import * as i18n from './translations'; + +export const validateCommonConfig = ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: JiraPublicConfigurationType +) => { + if ( + configObject.incidentConfiguration !== null && + isEmpty(configObject.incidentConfiguration.mapping) + ) { + return i18n.MAPPING_EMPTY; + } + + try { + configurationUtilities.ensureUriAllowed(configObject.apiUrl); + } catch (allowedListError) { + return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message); + } +}; + +export const validateCommonSecrets = ( + configurationUtilities: ActionsConfigurationUtilities, + secrets: JiraSecretConfigurationType +) => {}; export const validate: ExternalServiceValidation = { config: validateCommonConfig, 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 0bb096ecd0f62..7a68781bb9a75 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 @@ -91,7 +91,7 @@ describe('api', () => { expect(externalService.updateIncident).not.toHaveBeenCalled(); }); - test('it calls updateIncident correctly', async () => { + test('it calls updateIncident correctly when creating an incident and having comments', async () => { const params = { ...apiParams, externalId: null }; await api.pushToService({ externalService, @@ -103,7 +103,7 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledTimes(2); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { incident: { - comments: 'A comment', + comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', short_description: @@ -114,7 +114,7 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { incident: { - comments: 'Another comment', + comments: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', short_description: @@ -215,7 +215,7 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { incident: { - comments: 'A comment', + comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', short_description: 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 3281832941558..c8e6147ecef46 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 @@ -10,11 +10,13 @@ import { HandshakeApiHandlerArgs, GetIncidentApiHandlerArgs, ExternalServiceApi, + PushToServiceApiParams, + PushToServiceResponse, } from './types'; // TODO: to remove, need to support Case import { transformers } from '../case/transformers'; -import { PushToServiceResponse, TransformFieldsArgs } from './case_types'; +import { TransformFieldsArgs, Comment, EntityInformation } from '../case/common_types'; import { prepareFieldsForTransformation } from '../case/utils'; const handshakeHandler = async ({ @@ -92,9 +94,10 @@ const pushToServiceHandler = async ({ mapping.get('comments')?.actionType !== 'nothing' ) { res.comments = []; + const commentsTransformed = transformComments(comments, ['informationAdded']); const fieldsKey = mapping.get('comments')?.target ?? 'comments'; - for (const currentComment of comments) { + for (const currentComment of commentsTransformed) { await externalService.updateIncident({ incidentId: res.id, incident: { @@ -118,7 +121,7 @@ export const transformFields = ({ params, fields, currentIncident, -}: TransformFieldsArgs): Record => { +}: TransformFieldsArgs): Record => { return fields.reduce((prev, cur) => { const transform = flow(...cur.pipes.map((p) => transformers[p])); return { @@ -126,20 +129,35 @@ export const transformFields = ({ [cur.key]: transform({ value: cur.value, date: params.updatedAt ?? params.createdAt, - user: - (params.updatedBy != null - ? params.updatedBy.fullName - ? params.updatedBy.fullName - : params.updatedBy.username - : params.createdBy.fullName - ? params.createdBy.fullName - : params.createdBy.username) ?? '', + user: getEntity(params), previousValue: currentIncident ? currentIncident[cur.key] : '', }).value, }; }, {}); }; +export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => { + return comments.map((c) => ({ + ...c, + comment: flow(...pipes.map((p) => transformers[p]))({ + value: c.comment, + date: c.updatedAt ?? c.createdAt, + user: getEntity(c), + }).value, + })); +}; + +export const getEntity = (entity: EntityInformation): string => + (entity.updatedBy != null + ? entity.updatedBy.fullName + ? entity.updatedBy.fullName + : entity.updatedBy.username + : entity.createdBy != null + ? entity.createdBy.fullName + ? entity.createdBy.fullName + : entity.createdBy.username + : '') ?? ''; + export const api: ExternalServiceApi = { handshake: handshakeHandler, pushToService: pushToServiceHandler, 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 3addbe7c54dac..41a577918b18e 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 @@ -24,11 +24,11 @@ import { ExecutorSubActionPushParams, ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, + PushToServiceResponse, } from './types'; // TODO: to remove, need to support Case import { buildMap, mapParams } from '../case/utils'; -import { PushToServiceResponse } from './case_types'; interface GetActionTypeParams { logger: Logger; 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 5f22fcd4fdc85..55a14e4528acf 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,7 @@ */ import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; -import { MapRecord } from './case_types'; +import { MapRecord } from '../case/common_types'; const createMock = (): jest.Mocked => { const service = { 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 82afebaaee445..921de42adfcaf 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 @@ -5,7 +5,11 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from './case_shema'; +import { + CommentSchema, + EntityInformation, + IncidentConfigurationSchema, +} from '../case/common_schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 05c7d805a1852..7cc97a241c4bc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -10,8 +10,8 @@ export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { defaultMessage: 'ServiceNow', }); -export const WHITE_LISTED_ERROR = (message: string) => - i18n.translate('xpack.actions.builtin.configuration.apiWhitelistError', { +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', { defaultMessage: 'error configuring connector action: {message}', values: { message, 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 0db9b6642ea5c..e8fcfac45d789 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 @@ -16,8 +16,8 @@ import { ExecutorSubActionHandshakeParamsSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { IncidentConfigurationSchema } from './case_shema'; -import { PushToServiceResponse } from './case_types'; +import { ExternalServiceCommentResponse } from '../case/common_types'; +import { IncidentConfigurationSchema } from '../case/common_schema'; import { Logger } from '../../../../../../src/core/server'; export type ServiceNowPublicConfigurationType = TypeOf< @@ -52,6 +52,9 @@ export interface ExternalServiceIncidentResponse { url: string; pushedDate: string; } +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} export type ExternalServiceParams = Record; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts index 6eec3b8d63b86..87bbfd9c7ea95 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts @@ -27,8 +27,8 @@ export const validateCommonConfig = ( try { configurationUtilities.ensureUriAllowed(configObject.apiUrl); - } catch (allowListError) { - return i18n.WHITE_LISTED_ERROR(allowListError.message); + } catch (allowedListError) { + return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message); } }; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index bd12c258a5388..15a318002390f 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -28,5 +28,11 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; export const SERVICENOW_ACTION_TYPE_ID = '.servicenow'; +export const JIRA_ACTION_TYPE_ID = '.jira'; +export const RESILIENT_ACTION_TYPE_ID = '.resilient'; -export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira', '.resilient']; +export const SUPPORTED_CONNECTORS = [ + SERVICENOW_ACTION_TYPE_ID, + JIRA_ACTION_TYPE_ID, + RESILIENT_ACTION_TYPE_ID, +]; diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index 28e75dd2f8c32..a22d7ae5cea21 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -12,6 +12,7 @@ import { CASE_CONFIGURE_CONNECTORS_URL, SUPPORTED_CONNECTORS, SERVICENOW_ACTION_TYPE_ID, + JIRA_ACTION_TYPE_ID, } from '../../../../../common/constants'; /* @@ -36,8 +37,9 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou (action) => SUPPORTED_CONNECTORS.includes(action.actionTypeId) && // Need this filtering temporary to display only Case owned ServiceNow connectors - (action.actionTypeId !== SERVICENOW_ACTION_TYPE_ID || - (action.actionTypeId === SERVICENOW_ACTION_TYPE_ID && action.config!.isCaseOwned)) + (![SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID].includes(action.actionTypeId) || + ([SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID].includes(action.actionTypeId) && + action.config?.isCaseOwned === true)) ); return response.ok({ body: results }); } catch (error) { diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts index 833f85712b5fa..9e6982ea20301 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ServiceNowConnectorConfiguration } from '../../../../../triggers_actions_ui/public/common'; -import { connector as jiraConnectorConfig } from './jira/config'; +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import { + ServiceNowConnectorConfiguration, + JiraConnectorConfiguration, +} from '../../../../../triggers_actions_ui/public/common'; import { connector as resilientConnectorConfig } from './resilient/config'; import { ConnectorConfiguration } from './types'; export const connectorsConfiguration: Record = { '.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration, - '.jira': jiraConnectorConfig, + '.jira': JiraConnectorConfiguration as ConnectorConfiguration, '.resilient': resilientConnectorConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts index f32e1e0df184e..33afa82c84f34 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getActionType as jiraActionType } from './jira'; export { getActionType as resilientActionType } from './resilient'; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx deleted file mode 100644 index 0737db3cd08eb..0000000000000 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/flyout.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldPassword, - EuiSpacer, -} from '@elastic/eui'; - -import * as i18n from './translations'; -import { ConnectorFlyoutFormProps } from '../types'; -import { JiraActionConnector } from './types'; -import { withConnectorFlyout } from '../components/connector_flyout'; - -const JiraConnectorForm: React.FC> = ({ - errors, - action, - onChangeSecret, - onBlurSecret, - onChangeConfig, - onBlurConfig, -}) => { - const { projectKey } = action.config; - const { email, apiToken } = action.secrets; - const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey != null; - const isEmailInvalid: boolean = errors.email.length > 0 && email != null; - const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken != null; - - return ( - <> - - - - onChangeConfig('projectKey', evt.target.value)} - onBlur={() => onBlurConfig('projectKey')} - /> - - - - - - - - onChangeSecret('email', evt.target.value)} - onBlur={() => onBlurSecret('email')} - /> - - - - - - - - onChangeSecret('apiToken', evt.target.value)} - onBlur={() => onBlurSecret('apiToken')} - /> - - - - - ); -}; - -export const JiraConnectorFlyout = withConnectorFlyout({ - ConnectorFormComponent: JiraConnectorForm, - secretKeys: ['email', 'apiToken'], - configKeys: ['projectKey'], - connectorActionTypeId: '.jira', -}); - -// eslint-disable-next-line import/no-default-export -export { JiraConnectorFlyout as default }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/index.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/index.tsx deleted file mode 100644 index cead392010dc7..0000000000000 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { lazy } from 'react'; -import { - ValidationResult, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../triggers_actions_ui/public/types'; - -import { connector } from './config'; -import { createActionType } from '../utils'; -import logo from './logo.svg'; -import { JiraActionConnector } from './types'; -import * as i18n from './translations'; - -interface Errors { - projectKey: string[]; - email: string[]; - apiToken: string[]; -} - -const validateConnector = (action: JiraActionConnector): ValidationResult => { - const errors: Errors = { - projectKey: [], - email: [], - apiToken: [], - }; - - if (!action.config.projectKey) { - errors.projectKey = [...errors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED]; - } - - if (!action.secrets.email) { - errors.email = [...errors.email, i18n.JIRA_EMAIL_REQUIRED]; - } - - if (!action.secrets.apiToken) { - errors.apiToken = [...errors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; - } - - return { errors }; -}; - -export const getActionType = createActionType({ - id: connector.id, - iconClass: logo, - selectMessage: i18n.JIRA_DESC, - actionTypeTitle: connector.name, - validateConnector, - actionConnectorFields: lazy(() => import('./flyout')), -}); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts deleted file mode 100644 index d7abf77a58d4c..0000000000000 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/translations.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export * from '../translations'; - -export const JIRA_DESC = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.selectMessageText', - { - defaultMessage: 'Push or update Security case data to a new issue in Jira', - } -); - -export const JIRA_TITLE = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.actionTypeTitle', - { - defaultMessage: 'Jira', - } -); - -export const JIRA_PROJECT_KEY_LABEL = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.projectKey', - { - defaultMessage: 'Project key', - } -); - -export const JIRA_PROJECT_KEY_REQUIRED = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.requiredProjectKeyTextField', - { - defaultMessage: 'Project key is required', - } -); - -export const JIRA_EMAIL_LABEL = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.emailTextFieldLabel', - { - defaultMessage: 'Email or Username', - } -); - -export const JIRA_EMAIL_REQUIRED = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.requiredEmailTextField', - { - defaultMessage: 'Email or Username is required', - } -); - -export const JIRA_API_TOKEN_LABEL = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.apiTokenTextFieldLabel', - { - defaultMessage: 'API token or Password', - } -); - -export const JIRA_API_TOKEN_REQUIRED = i18n.translate( - 'xpack.securitySolution.case.connectors.jira.requiredApiTokenTextField', - { - defaultMessage: 'API token or Password is required', - } -); - -export const MAPPING_FIELD_SUMMARY = i18n.translate( - 'xpack.securitySolution.case.configureCases.mappingFieldSummary', - { - defaultMessage: 'Summary', - } -); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/types.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/jira/types.ts deleted file mode 100644 index fafb4a0d41fb3..0000000000000 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-restricted-imports */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ - -import { - JiraPublicConfigurationType, - JiraSecretConfigurationType, -} from '../../../../../../actions/server/builtin_action_types/jira/types'; - -export { JiraFieldsType } from '../../../../../../case/common/api/connectors'; - -export * from '../types'; - -export interface JiraActionConnector { - config: JiraPublicConfigurationType; - secrets: JiraSecretConfigurationType; -} diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 1017cbb6a2c61..10bbbbfa72719 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -21,7 +21,7 @@ import { import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; -import { jiraActionType, resilientActionType } from './common/lib/connectors'; +import { resilientActionType } from './common/lib/connectors'; import { PluginSetup, PluginStart, @@ -96,7 +96,6 @@ export class Plugin implements IPlugin { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8b9409f01087c..2f2699bc4b49c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4446,7 +4446,6 @@ "xpack.actions.builtin.case.connectorApiNullError": "コネクター[apiUrl]が必要です", "xpack.actions.builtin.case.jiraTitle": "Jira", "xpack.actions.builtin.case.resilientTitle": "IBM Resilient", - "xpack.actions.builtin.configuration.apiWhitelistError": "コネクターアクションの構成エラー:{message}", "xpack.actions.builtin.email.errorSendingErrorMessage": "エラー送信メールアドレス", "xpack.actions.builtin.emailTitle": "メール", "xpack.actions.builtin.esIndex.errorIndexingErrorMessage": "エラーインデックス作成ドキュメント", @@ -15115,7 +15114,6 @@ "xpack.securitySolution.case.configureCases.mappingFieldDescription": "説明", "xpack.securitySolution.case.configureCases.mappingFieldName": "名前", "xpack.securitySolution.case.configureCases.mappingFieldNotMapped": "マップされません", - "xpack.securitySolution.case.configureCases.mappingFieldSummary": "まとめ", "xpack.securitySolution.case.configureCases.noConnector": "コネクターを選択していません", "xpack.securitySolution.case.configureCases.updateConnector": "コネクターを更新", "xpack.securitySolution.case.configureCases.updateSelectedConnector": "{ connectorName }を更新", @@ -15139,14 +15137,6 @@ "xpack.securitySolution.case.connectors.common.requiredPasswordTextField": "パスワードが必要です", "xpack.securitySolution.case.connectors.common.requiredUsernameTextField": "ユーザー名が必要です", "xpack.securitySolution.case.connectors.common.usernameTextFieldLabel": "ユーザー名", - "xpack.securitySolution.case.connectors.jira.actionTypeTitle": "Jira", - "xpack.securitySolution.case.connectors.jira.apiTokenTextFieldLabel": "APIトークンまたはパスワード", - "xpack.securitySolution.case.connectors.jira.emailTextFieldLabel": "電子メールアドレスまたはユーザー名", - "xpack.securitySolution.case.connectors.jira.projectKey": "プロジェクトキー", - "xpack.securitySolution.case.connectors.jira.requiredApiTokenTextField": "APIトークンまたはパスワードが必要です", - "xpack.securitySolution.case.connectors.jira.requiredEmailTextField": "電子メールアドレスまたはユーザー名が必要です", - "xpack.securitySolution.case.connectors.jira.requiredProjectKeyTextField": "プロジェクトキーが必要です", - "xpack.securitySolution.case.connectors.jira.selectMessageText": "Jiraでセキュリティケースデータを更新するか、新しいインシデントにプッシュ", "xpack.securitySolution.case.connectors.resilient.actionTypeTitle": "IBM Resilient", "xpack.securitySolution.case.connectors.resilient.apiKeyId": "APIキーID", "xpack.securitySolution.case.connectors.resilient.apiKeySecret": "APIキーシークレット", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b9fb6340e38cf..c768fd120d827 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4447,7 +4447,6 @@ "xpack.actions.builtin.case.connectorApiNullError": "需要指定连接器 [apiUrl]", "xpack.actions.builtin.case.jiraTitle": "Jira", "xpack.actions.builtin.case.resilientTitle": "IBM Resilient", - "xpack.actions.builtin.configuration.apiWhitelistError": "配置连接器操作时出错:{message}", "xpack.actions.builtin.email.errorSendingErrorMessage": "发送电子邮件时出错", "xpack.actions.builtin.emailTitle": "电子邮件", "xpack.actions.builtin.esIndex.errorIndexingErrorMessage": "索引文档时出错", @@ -15124,7 +15123,6 @@ "xpack.securitySolution.case.configureCases.mappingFieldDescription": "描述", "xpack.securitySolution.case.configureCases.mappingFieldName": "名称", "xpack.securitySolution.case.configureCases.mappingFieldNotMapped": "未映射", - "xpack.securitySolution.case.configureCases.mappingFieldSummary": "摘要", "xpack.securitySolution.case.configureCases.noConnector": "未选择连接器", "xpack.securitySolution.case.configureCases.updateConnector": "更新连接器", "xpack.securitySolution.case.configureCases.updateSelectedConnector": "更新 { connectorName }", @@ -15148,14 +15146,6 @@ "xpack.securitySolution.case.connectors.common.requiredPasswordTextField": "“密码”必填", "xpack.securitySolution.case.connectors.common.requiredUsernameTextField": "“用户名”必填", "xpack.securitySolution.case.connectors.common.usernameTextFieldLabel": "用户名", - "xpack.securitySolution.case.connectors.jira.actionTypeTitle": "Jira", - "xpack.securitySolution.case.connectors.jira.apiTokenTextFieldLabel": "API 令牌或密码", - "xpack.securitySolution.case.connectors.jira.emailTextFieldLabel": "电子邮件或用户名", - "xpack.securitySolution.case.connectors.jira.projectKey": "项目键", - "xpack.securitySolution.case.connectors.jira.requiredApiTokenTextField": "“API 令牌或密码”必填", - "xpack.securitySolution.case.connectors.jira.requiredEmailTextField": "“电子邮件或用户名”必填", - "xpack.securitySolution.case.connectors.jira.requiredProjectKeyTextField": "“项目键”必填", - "xpack.securitySolution.case.connectors.jira.selectMessageText": "将 Security 案例数据推送或更新到 Jira 中的新问题", "xpack.securitySolution.case.connectors.resilient.actionTypeTitle": "IBM Resilient", "xpack.securitySolution.case.connectors.resilient.apiKeyId": "API 密钥 ID", "xpack.securitySolution.case.connectors.resilient.apiKeySecret": "API 密钥密码", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/field_mapping.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping.tsx similarity index 95% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/field_mapping.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping.tsx index 52b881a1eb75f..a3382513d2bcb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/field_mapping.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping.tsx @@ -13,9 +13,8 @@ import * as i18n from './translations'; import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; import { ThirdPartyField as ConnectorConfigurationThirdPartyField } from './types'; -import { CasesConfigurationMapping } from '../types'; -import { connectorConfiguration } from '../config'; -import { createDefaultMapping } from '../servicenow_connectors'; +import { CasesConfigurationMapping } from './types'; +import { createDefaultMapping } from './utils'; const FieldRowWrapper = styled.div` margin-top: 8px; @@ -70,15 +69,15 @@ const getThirdPartyOptions = ( export interface FieldMappingProps { disabled: boolean; mapping: CasesConfigurationMapping[] | null; - connectorActionTypeId: string; onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; + connectorConfiguration: Record; } const FieldMappingComponent: React.FC = ({ disabled, mapping, onChangeMapping, - connectorActionTypeId, + connectorConfiguration, }) => { const onChangeActionType = useCallback( (caseField: string, newActionType: string) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/field_mapping_row.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping_row.tsx similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/field_mapping_row.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/field_mapping_row.tsx diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/index.ts similarity index 51% rename from x-pack/plugins/actions/server/builtin_action_types/jira/config.ts rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/index.ts index 54f28e447010a..2de9b87ead3fe 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/index.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ExternalServiceConfiguration } from '../case/types'; -import * as i18n from './translations'; - -export const config: ExternalServiceConfiguration = { - id: '.jira', - name: i18n.NAME, - minimumLicenseRequired: 'gold', -}; +export * from './types'; +export * from './field_mapping'; +export * from './field_mapping_row'; +export * from './utils'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/translations.ts similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/translations.ts rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/translations.ts diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/types.ts similarity index 72% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/types.ts rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/types.ts index 6cd2200e1dc74..3571db39b596a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ActionType } from '../../../../../types'; +import { ActionType } from '../../../../types'; export { ActionType }; @@ -14,3 +14,8 @@ export interface ThirdPartyField { defaultSourceField: string; defaultActionType: string; } +export interface CasesConfigurationMapping { + source: string; + target: string; + actionType: string; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/utils.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/utils.ts similarity index 76% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/utils.ts rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/utils.ts index a173d90515302..b14b1b76427c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/case_mappings/utils.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/case_mappings/utils.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CasesConfigurationMapping } from '../types'; +import { CasesConfigurationMapping } from './types'; export const setActionTypeToMapping = ( caseField: string, @@ -36,3 +36,13 @@ export const setThirdPartyToMapping = ( } return item; }); + +export const createDefaultMapping = (fields: Record): CasesConfigurationMapping[] => + Object.keys(fields).map( + (key) => + ({ + source: fields[key].defaultSourceField, + target: key, + actionType: fields[key].defaultActionType, + } as CasesConfigurationMapping) + ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx index ecdfefa109f58..be3e8a31820c4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx @@ -17,6 +17,7 @@ describe('EmailParamsFields renders', () => { subject: 'test', message: 'test message', }; + const wrapper = mountWithIntl( > { + return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'issueTypes', subActionParams: {} }, + }), + signal, + }); +} + +export async function getFieldsByIssueType({ + http, + signal, + connectorId, + id, +}: { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + id: string; +}): Promise> { + return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, { + body: JSON.stringify({ + params: { subAction: 'fieldsByIssueType', subActionParams: { id } }, + }), + signal, + }); +} diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts similarity index 87% rename from x-pack/plugins/security_solution/public/common/lib/connectors/jira/config.ts rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts index e6151a54bff74..628600ee91c8e 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/config.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/config.ts @@ -4,19 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConnectorConfiguration } from './types'; - import * as i18n from './translations'; import logo from './logo.svg'; -export const connector: ConnectorConfiguration = { +export const connectorConfiguration = { id: '.jira', name: i18n.JIRA_TITLE, logo, enabled: true, enabledInConfig: true, enabledInLicense: true, - minimumLicenseRequired: 'platinum', + minimumLicenseRequired: 'gold', fields: { summary: { label: i18n.MAPPING_FIELD_SUMMARY, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/index.ts new file mode 100644 index 0000000000000..a0170f9d84e9b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getJiraActionType } from './jira'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx new file mode 100644 index 0000000000000..61923d8f78b51 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { JiraActionConnector } from './types'; + +const ACTION_TYPE_ID = '.jira'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + }); +}); + +describe('jira connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + email: 'email', + apiToken: 'apiToken', + }, + id: 'test', + actionTypeId: '.jira', + name: 'jira', + isPreconfigured: false, + config: { + apiUrl: 'https://siem-kibana.atlassian.net', + projectKey: 'CK', + }, + } as JiraActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + apiUrl: [], + email: [], + apiToken: [], + projectKey: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = ({ + secrets: { + email: 'user', + }, + id: '.jira', + actionTypeId: '.jira', + name: 'jira', + config: {}, + } as unknown) as JiraActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + apiUrl: ['URL is required.'], + email: [], + apiToken: ['API token or Password is required'], + projectKey: ['Project key is required'], + }, + }); + }); +}); + +describe('jira action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + subActionParams: { title: 'some title {{test}}' }, + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { title: [] }, + }); + }); + + test('params validation fails when body is not valid', () => { + const actionParams = { + subActionParams: { title: '' }, + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + title: ['Title is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx new file mode 100644 index 0000000000000..fd36bd6aeab0a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { lazy } from 'react'; +import { ValidationResult, ActionTypeModel } from '../../../../types'; +import { connectorConfiguration } from './config'; +import logo from './logo.svg'; +import { JiraActionConnector, JiraActionParams } from './types'; +import * as i18n from './translations'; +import { isValidUrl } from '../../../lib/value_validators'; + +const validateConnector = (action: JiraActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + apiUrl: new Array(), + projectKey: new Array(), + email: new Array(), + apiToken: new Array(), + }; + validationResult.errors = errors; + + if (!action.config.apiUrl) { + errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRED]; + } + + if (action.config.apiUrl && !isValidUrl(action.config.apiUrl, 'https:')) { + errors.apiUrl = [...errors.apiUrl, i18n.API_URL_INVALID]; + } + + if (!action.config.projectKey) { + errors.projectKey = [...errors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED]; + } + + if (!action.secrets.email) { + errors.email = [...errors.email, i18n.JIRA_EMAIL_REQUIRED]; + } + + if (!action.secrets.apiToken) { + errors.apiToken = [...errors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; + } + + return validationResult; +}; + +export function getActionType(): ActionTypeModel { + return { + id: connectorConfiguration.id, + iconClass: logo, + selectMessage: i18n.JIRA_DESC, + actionTypeTitle: connectorConfiguration.name, + validateConnector, + actionConnectorFields: lazy(() => import('./jira_connectors')), + validateParams: (actionParams: JiraActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + title: new Array(), + }; + validationResult.errors = errors; + if (actionParams.subActionParams && !actionParams.subActionParams.title?.length) { + errors.title.push(i18n.TITLE_REQUIRED); + } + return validationResult; + }, + actionParamsFields: lazy(() => import('./jira_params')), + }; +} 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 new file mode 100644 index 0000000000000..2cac1819d552d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DocLinksStart } from 'kibana/public'; +import JiraConnectorFields from './jira_connectors'; +import { JiraActionConnector } from './types'; + +describe('JiraActionConnectorFields renders', () => { + test('alerting Jira connector fields is rendered', () => { + const actionConnector = { + secrets: { + email: 'email', + apiToken: 'token', + }, + id: 'test', + actionTypeId: '.jira', + isPreconfigured: false, + name: 'jira', + config: { + apiUrl: 'https://test/', + projectKey: 'CK', + }, + } as JiraActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + /> + ); + + expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="connector-jira-project-key-form-input"]').length > 0 + ).toBeTruthy(); + + expect( + wrapper.find('[data-test-subj="connector-jira-email-form-input"]').length > 0 + ).toBeTruthy(); + + expect( + wrapper.find('[data-test-subj="connector-jira-apiToken-form-input"]').length > 0 + ).toBeTruthy(); + }); + + test('case specific Jira connector fields is rendered', () => { + const actionConnector = { + secrets: { + email: 'email', + apiToken: 'token', + }, + id: 'test', + actionTypeId: '.jira', + isPreconfigured: false, + name: 'jira', + config: { + apiUrl: 'https://test/', + projectKey: 'CK', + }, + } as JiraActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + readOnly={false} + consumer={'case'} + /> + ); + expect(wrapper.find('[data-test-subj="case-jira-mappings"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="connector-jira-project-key-form-input"]').length > 0 + ).toBeTruthy(); + + expect( + wrapper.find('[data-test-subj="connector-jira-email-form-input"]').length > 0 + ).toBeTruthy(); + + expect( + wrapper.find('[data-test-subj="connector-jira-apiToken-form-input"]').length > 0 + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx new file mode 100644 index 0000000000000..2ab9843c143b9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; + +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiSpacer, +} from '@elastic/eui'; + +import { isEmpty } from 'lodash'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { CasesConfigurationMapping, FieldMapping, createDefaultMapping } from '../case_mappings'; + +import * as i18n from './translations'; +import { JiraActionConnector } from './types'; +import { connectorConfiguration } from './config'; + +const JiraConnectorFields: React.FC> = ({ + action, + editActionSecrets, + editActionConfig, + errors, + consumer, + readOnly, + docLinks, +}) => { + // TODO: remove incidentConfiguration later, when Case Jira will move their fields to the level of action execution + const { apiUrl, projectKey, incidentConfiguration, isCaseOwned } = action.config; + const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; + + const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null; + + const { email, apiToken } = action.secrets; + + const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey != null; + const isEmailInvalid: boolean = errors.email.length > 0 && email != null; + const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken != null; + + // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution + if (consumer === 'case') { + if (isEmpty(mapping)) { + editActionConfig('incidentConfiguration', { + mapping: createDefaultMapping(connectorConfiguration.fields as any), + }); + } + if (!isCaseOwned) { + editActionConfig('isCaseOwned', true); + } + } + + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const handleOnChangeSecretConfig = useCallback( + (key: string, value: string) => editActionSecrets(key, value), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const handleOnChangeMappingConfig = useCallback( + (newMapping: CasesConfigurationMapping[]) => + editActionConfig('incidentConfiguration', { + ...action.config.incidentConfiguration, + mapping: newMapping, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [action.config] + ); + + return ( + <> + + + + handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + + + + handleOnChangeActionConfig('projectKey', evt.target.value)} + onBlur={() => { + if (!projectKey) { + editActionConfig('projectKey', ''); + } + }} + /> + + + + + + + + handleOnChangeSecretConfig('email', evt.target.value)} + onBlur={() => { + if (!email) { + editActionSecrets('email', ''); + } + }} + /> + + + + + + + + handleOnChangeSecretConfig('apiToken', evt.target.value)} + onBlur={() => { + if (!apiToken) { + editActionSecrets('apiToken', ''); + } + }} + /> + + + + {consumer === 'case' && ( // TODO: remove this block later, when Case Jira will move their fields to the level of action execution + <> + + + + + + + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { JiraConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx new file mode 100644 index 0000000000000..26d358310741c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import JiraParamsFields from './jira_params'; +import { DocLinksStart } from 'kibana/public'; + +import { useGetIssueTypes } from './use_get_issue_types'; +import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; + +jest.mock('../../../app_context', () => { + const post = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ http: { post } })), + }; +}); + +jest.mock('./use_get_issue_types'); +jest.mock('./use_get_fields_by_issue_type'); + +const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; +const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; + +const actionParams = { + subAction: 'pushToService', + subActionParams: { + title: 'sn title', + description: 'some description', + comments: [{ commentId: '1', comment: 'comment for jira' }], + issueType: '10006', + labels: ['kibana'], + priority: 'High', + savedObjectId: '123', + externalId: null, + }, +}; +const connector = { + secrets: {}, + config: {}, + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, +}; + +describe('JiraParamsFields renders', () => { + const useGetIssueTypesResponse = { + isLoading: false, + issueTypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Bug', + }, + ], + }; + + const useGetFieldsByIssueTypeResponse = { + isLoading: false, + fields: { + summary: { allowedValues: [], defaultValue: {} }, + labels: { allowedValues: [], defaultValue: {} }, + description: { allowedValues: [], defaultValue: {} }, + priority: { + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3' }, + }, + }, + }; + + beforeEach(() => { + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse); + }); + + test('all params fields are rendered', () => { + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + actionConnector={connector} + /> + ); + expect(wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('value')).toStrictEqual( + '10006' + ); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('value')).toStrictEqual( + 'High' + ); + expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="labelsComboBox"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + }); + + test('it shows loading when loading issue types', () => { + useGetIssueTypesMock.mockReturnValue({ ...useGetIssueTypesResponse, isLoading: true }); + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + actionConnector={connector} + /> + ); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('isLoading') + ).toBeTruthy(); + }); + + test('it shows loading when loading fields', () => { + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + isLoading: true, + }); + + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + actionConnector={connector} + /> + ); + + expect( + wrapper.find('[data-test-subj="prioritySelect"]').first().prop('isLoading') + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="labelsComboBox"]').first().prop('isLoading') + ).toBeTruthy(); + }); + + test('it disabled the fields when loading issue types', () => { + useGetIssueTypesMock.mockReturnValue({ ...useGetIssueTypesResponse, isLoading: true }); + + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + actionConnector={connector} + /> + ); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled') + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="labelsComboBox"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + + test('it disabled the fields when loading fields', () => { + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + isLoading: true, + }); + + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + actionConnector={connector} + /> + ); + + expect( + wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled') + ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="labelsComboBox"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + + test('hide unsupported fields', () => { + useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); + useGetFieldsByIssueTypeMock.mockReturnValue({ + ...useGetFieldsByIssueTypeResponse, + fields: {}, + }); + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + actionConnector={connector} + /> + ); + + expect(wrapper.find('[data-test-subj="issueTypeSelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="titleInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="labelsComboBox"]').exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx new file mode 100644 index 0000000000000..bde3d67ffd65f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -0,0 +1,319 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useEffect, useState, useMemo } from 'react'; +import { map } from 'lodash/fp'; +import { EuiFormRow, EuiComboBox, EuiSelectOption, EuiHorizontalRule } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiSelect } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; + +import { useAppDependencies } from '../../../app_context'; +import { ActionParamsProps } from '../../../../types'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; +import { JiraActionParams } from './types'; +import { useGetIssueTypes } from './use_get_issue_types'; +import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; + +const JiraParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + actionConnector, +}) => { + const { title, description, comments, issueType, priority, labels, savedObjectId } = + actionParams.subActionParams || {}; + + const [issueTypesSelectOptions, setIssueTypesSelectOptions] = useState([]); + const [firstLoad, setFirstLoad] = useState(false); + const [prioritiesSelectOptions, setPrioritiesSelectOptions] = useState([]); + const { http, toastNotifications } = useAppDependencies(); + + useEffect(() => { + setFirstLoad(true); + }, []); + + const { isLoading: isLoadingIssueTypes, issueTypes } = useGetIssueTypes({ + http, + toastNotifications, + actionConnector, + }); + + const { isLoading: isLoadingFields, fields } = useGetFieldsByIssueType({ + http, + toastNotifications, + actionConnector, + issueType, + }); + + const hasLabels = useMemo(() => Object.prototype.hasOwnProperty.call(fields, 'labels'), [fields]); + const hasDescription = useMemo( + () => Object.prototype.hasOwnProperty.call(fields, 'description'), + [fields] + ); + const hasPriority = useMemo(() => Object.prototype.hasOwnProperty.call(fields, 'priority'), [ + fields, + ]); + + useEffect(() => { + const options = issueTypes.map((type) => ({ + value: type.id ?? '', + text: type.name ?? '', + })); + + setIssueTypesSelectOptions(options); + }, [issueTypes]); + + useEffect(() => { + if (issueType != null && fields != null) { + const priorities = fields.priority?.allowedValues ?? []; + const options = map( + (p) => ({ + value: p.name, + text: p.name, + }), + priorities + ); + setPrioritiesSelectOptions(options); + } + }, [fields, issueType]); + + const labelOptions = useMemo(() => (labels ? labels.map((label: string) => ({ label })) : []), [ + labels, + ]); + + const editSubActionProperty = (key: string, value: any) => { + const newProps = { ...actionParams.subActionParams, [key]: value }; + editAction('subActionParams', newProps, index); + }; + + // Reset parameters when changing connector + useEffect(() => { + if (!firstLoad) { + return; + } + + setIssueTypesSelectOptions([]); + editAction('subActionParams', { title, comments, description: '', savedObjectId }, index); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector]); + + // Reset fields when changing connector or issue type + useEffect(() => { + if (!firstLoad) { + return; + } + + setPrioritiesSelectOptions([]); + editAction( + 'subActionParams', + { title, issueType, comments, description: '', savedObjectId }, + index + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [issueType, savedObjectId]); + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'pushToService', index); + } + if (!savedObjectId && messageVariables?.find((variable) => variable.name === 'alertId')) { + editSubActionProperty('savedObjectId', '{{alertId}}'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + actionConnector, + actionParams.subAction, + index, + savedObjectId, + issueTypesSelectOptions, + issueType, + ]); + + // Set default issue type + useEffect(() => { + if (!issueType && issueTypesSelectOptions.length > 0) { + editSubActionProperty('issueType', issueTypesSelectOptions[0].value as string); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [issueTypes, issueTypesSelectOptions]); + + // Set default priority + useEffect(() => { + if (!priority && prioritiesSelectOptions.length > 0) { + editSubActionProperty('priority', prioritiesSelectOptions[0].value as string); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionConnector, issueType, prioritiesSelectOptions]); + + return ( + + <> + + { + editSubActionProperty('issueType', e.target.value); + }} + /> + + + <> + {hasPriority && ( + <> + + + + { + editSubActionProperty('priority', e.target.value); + }} + /> + + + + + + )} + 0 && title !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.titleFieldLabel', + { + defaultMessage: 'Summary', + } + )} + > + + + + {hasLabels && ( + <> + + + + { + const newOptions = [...labelOptions, { label: searchValue }]; + editSubActionProperty( + 'labels', + newOptions.map((newOption) => newOption.label) + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editSubActionProperty( + 'labels', + selectedOptions.map((selectedOption) => selectedOption.label) + ); + }} + onBlur={() => { + if (!labels) { + editSubActionProperty('labels', []); + } + }} + isClearable={true} + data-test-subj="labelsComboBox" + /> + + + + + + )} + {hasDescription && ( + + )} + { + editSubActionProperty(key, [{ commentId: '1', comment: value }]); + }} + messageVariables={messageVariables} + paramsProperty={'comments'} + inputTargetValue={comments && comments.length > 0 ? comments[0].comment : ''} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.commentsTextAreaFieldLabel', + { + defaultMessage: 'Additional comments (optional)', + } + )} + errors={errors.comments as string[]} + /> + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { JiraParamsFields as default }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/jira/logo.svg b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/logo.svg similarity index 100% rename from x-pack/plugins/security_solution/public/common/lib/connectors/jira/logo.svg rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/logo.svg diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts new file mode 100644 index 0000000000000..bfcb72d1cb977 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const JIRA_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', + { + defaultMessage: 'Push or update data to a new issue in Jira', + } +); + +export const JIRA_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', + { + defaultMessage: 'Jira', + } +); + +export const API_URL_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiUrlTextFieldLabel', + { + defaultMessage: 'URL', + } +); + +export const API_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredApiUrlTextField', + { + defaultMessage: 'URL is required.', + } +); + +export const API_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.invalidApiUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const JIRA_PROJECT_KEY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.projectKey', + { + defaultMessage: 'Project key', + } +); + +export const JIRA_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredProjectKeyTextField', + { + defaultMessage: 'Project key is required', + } +); + +export const JIRA_EMAIL_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.emailTextFieldLabel', + { + defaultMessage: 'Email or Username', + } +); + +export const JIRA_EMAIL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredEmailTextField', + { + defaultMessage: 'Email or Username is required', + } +); + +export const JIRA_API_TOKEN_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiTokenTextFieldLabel', + { + defaultMessage: 'API token or Password', + } +); + +export const JIRA_API_TOKEN_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredApiTokenTextField', + { + defaultMessage: 'API token or Password is required', + } +); + +export const MAPPING_FIELD_SUMMARY = i18n.translate( + 'xpack.triggersActionsUI.case.configureCases.mappingFieldSummary', + { + defaultMessage: 'Summary', + } +); + +export const DESCRIPTION_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredDescriptionTextField', + { + defaultMessage: 'Description is required.', + } +); + +export const TITLE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.requiredTitleTextField', + { + defaultMessage: 'Title is required.', + } +); + +export const MAPPING_FIELD_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.mappingFieldDescription', + { + defaultMessage: 'Description', + } +); + +export const MAPPING_FIELD_COMMENTS = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.mappingFieldComments', + { + defaultMessage: 'Comments', + } +); + +export const ISSUE_TYPES_API_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueTypesMessage', + { + defaultMessage: 'Unable to get issue types', + } +); + +export const FIELDS_API_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetFieldsMessage', + { + defaultMessage: 'Unable to get fields', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts new file mode 100644 index 0000000000000..ff11199f35fea --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CasesConfigurationMapping } from '../case_mappings'; + +export interface JiraActionConnector { + config: JiraConfig; + secrets: JiraSecrets; +} + +export interface JiraActionParams { + subAction: string; + subActionParams: { + savedObjectId: string; + title: string; + description: string; + comments: Array<{ commentId: string; comment: string }>; + externalId: string | null; + issueType: string; + priority: string; + labels: string[]; + }; +} + +interface IncidentConfiguration { + mapping: CasesConfigurationMapping[]; +} + +interface JiraConfig { + apiUrl: string; + projectKey: string; + incidentConfiguration?: IncidentConfiguration; + isCaseOwned?: boolean; +} + +interface JiraSecrets { + email: string; + apiToken: string; +} + +// to remove diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx new file mode 100644 index 0000000000000..08715822e5277 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../../types'; +import { getFieldsByIssueType } from './api'; +import * as i18n from './translations'; + +interface Fields { + [key: string]: { + allowedValues: Array<{ name: string; id: string }> | []; + defaultValue: { name: string; id: string } | {}; + }; +} + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + issueType: string; + actionConnector?: ActionConnector; +} + +export interface UseGetFieldsByIssueType { + fields: Fields; + isLoading: boolean; +} + +export const useGetFieldsByIssueType = ({ + http, + toastNotifications, + actionConnector, + issueType, +}: Props): UseGetFieldsByIssueType => { + const [isLoading, setIsLoading] = useState(true); + const [fields, setFields] = useState({}); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!actionConnector || !issueType) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + try { + const res = await getFieldsByIssueType({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + id: issueType, + }); + + if (!didCancel) { + setIsLoading(false); + setFields(res.data ?? {}); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, actionConnector, issueType, toastNotifications]); + + return { + isLoading, + fields, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issue_types.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issue_types.tsx new file mode 100644 index 0000000000000..9ebaf5882d9b9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_issue_types.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../../types'; +import { getIssueTypes } from './api'; +import * as i18n from './translations'; + +type IssueTypes = Array<{ id: string; name: string }>; + +interface Props { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionConnector?: ActionConnector; +} + +export interface UseGetIssueTypes { + issueTypes: IssueTypes; + isLoading: boolean; +} + +export const useGetIssueTypes = ({ + http, + actionConnector, + toastNotifications, +}: Props): UseGetIssueTypes => { + const [isLoading, setIsLoading] = useState(true); + const [issueTypes, setIssueTypes] = useState([]); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!actionConnector) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + + try { + const res = await getIssueTypes({ + http, + signal: abortCtrl.current.signal, + connectorId: actionConnector.id, + }); + + if (!didCancel) { + setIsLoading(false); + setIssueTypes(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [http, actionConnector, toastNotifications]); + + return { + issueTypes, + isLoading, + }; +}; 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 f99a276305d75..a8f1ed8d55447 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 @@ -18,10 +18,11 @@ import { import { isEmpty } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; +import { CasesConfigurationMapping, FieldMapping, createDefaultMapping } from '../case_mappings'; + import * as i18n from './translations'; -import { ServiceNowActionConnector, CasesConfigurationMapping } from './types'; +import { ServiceNowActionConnector } from './types'; import { connectorConfiguration } from './config'; -import { FieldMapping } from './case_mappings/field_mapping'; const ServiceNowConnectorFields: React.FC @@ -184,15 +185,5 @@ const ServiceNowConnectorFields: React.FC): CasesConfigurationMapping[] => - Object.keys(fields).map( - (key) => - ({ - source: fields[key].defaultSourceField, - target: key, - actionType: fields[key].defaultActionType, - } as CasesConfigurationMapping) - ); - // eslint-disable-next-line import/no-default-export export { ServiceNowConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx index 2a29018d83ff4..2a2efdfbe35b1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx @@ -79,7 +79,14 @@ const ServiceNowParamsFields: React.FunctionComponent -

Incident

+

+ {i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title', + { + defaultMessage: 'Incident', + } + )} +

) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index.ts index 9dd3fd787f860..8b728b5e178b5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index.ts @@ -7,3 +7,4 @@ export * from './expression_items'; export { connectorConfiguration as ServiceNowConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config'; +export { connectorConfiguration as JiraConnectorConfiguration } from '../application/components/builtin_action_types/jira/config'; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 762f41ba3691c..109d473c56e66 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -54,6 +54,7 @@ export interface ActionParamsProps { messageVariables?: ActionVariable[]; defaultMessage?: string; docLinks: DocLinksStart; + actionConnector?: ActionConnector; } export interface Pagination { diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts new file mode 100644 index 0000000000000..025fd558ee1ca --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/jira.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts + +const mapping = [ + { + source: 'title', + target: 'summary', + actionType: 'nothing', + }, + { + source: 'description', + target: 'description', + actionType: 'nothing', + }, + { + source: 'comments', + target: 'comments', + actionType: 'nothing', + }, +]; + +// eslint-disable-next-line import/no-default-export +export default function jiraTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const mockJira = { + config: { + apiUrl: 'www.jiraisinkibanaactions.com', + incidentConfiguration: { mapping: [...mapping] }, + isCaseOwned: true, + }, + secrets: { + email: 'elastic', + apiToken: 'changeme', + }, + params: { + savedObjectId: '123', + title: 'a title', + description: 'a description', + labels: ['kibana'], + issueType: '10006', + priority: 'High', + externalId: null, + comments: [ + { + commentId: '456', + comment: 'first comment', + }, + ], + }, + }; + describe('jira', () => { + let jiraSimulatorURL: string = ''; + + // need to wait for kibanaServer to settle ... + before(() => { + jiraSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.JIRA) + ); + }); + + it('should return 403 when creating a jira action', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A jira action', + actionTypeId: '.jira', + config: { + apiUrl: jiraSimulatorURL, + projectKey: 'CK', + incidentConfiguration: { ...mockJira.config.incidentConfiguration }, + isCaseOwned: true, + }, + secrets: mockJira.secrets, + }) + .expect(403, { + statusCode: 403, + error: 'Forbidden', + message: + 'Action type .jira is disabled because your basic license does not support it. Please upgrade your license.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts index 1788a12afebf2..8f31e7f96b562 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts @@ -11,6 +11,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { describe('Actions', () => { loadTestFile(require.resolve('./builtin_action_types/email')); loadTestFile(require.resolve('./builtin_action_types/es_index')); + loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/pagerduty')); loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts index 4b65b7a8f2636..6041251dc28a4 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts @@ -105,6 +105,57 @@ export function initPlugin(router: IRouter, path: string) { }); } ); + + router.get( + { + path: `${path}/rest/capabilities`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + capabilities: {}, + }); + } + ); + + router.get( + { + path: `${path}/rest/api/2/issue/createmeta`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + projects: [ + { + issuetypes: [ + { + id: '10006', + name: 'Task', + }, + { + id: '10007', + name: 'Sub-task', + }, + ], + }, + ], + }); + } + ); } function jsonResponse( 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 0f7acf5ead1a1..88f0f02794c9b 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 @@ -38,6 +38,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); 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`); return allPaths; } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts index 3ffd58b945ddb..84fad699525a9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/jira.ts @@ -43,7 +43,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: 'www.jiraisinkibanaactions.com', projectKey: 'CK', - casesConfiguration: { mapping }, + incidentConfiguration: { mapping }, }, secrets: { apiToken: 'elastic', @@ -94,6 +94,8 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { ...mockJira.config, apiUrl: jiraSimulatorURL, + incidentConfiguration: mockJira.config.incidentConfiguration, + isCaseOwned: true, }, secrets: mockJira.secrets, }) @@ -107,7 +109,8 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - casesConfiguration: mockJira.config.casesConfiguration, + incidentConfiguration: mockJira.config.incidentConfiguration, + isCaseOwned: true, }, }); @@ -123,7 +126,8 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - casesConfiguration: mockJira.config.casesConfiguration, + incidentConfiguration: mockJira.config.incidentConfiguration, + isCaseOwned: true, }, }); }); @@ -178,7 +182,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: 'http://jira.mynonexistent.com', projectKey: mockJira.config.projectKey, - casesConfiguration: mockJira.config.casesConfiguration, + incidentConfiguration: mockJira.config.incidentConfiguration, }, secrets: mockJira.secrets, }) @@ -203,7 +207,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - casesConfiguration: mockJira.config.casesConfiguration, + incidentConfiguration: mockJira.config.incidentConfiguration, }, }) .expect(400) @@ -217,30 +221,6 @@ export default function jiraTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a jira action without casesConfiguration', async () => { - await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A jira action', - actionTypeId: '.jira', - config: { - apiUrl: jiraSimulatorURL, - projectKey: mockJira.config.projectKey, - }, - secrets: mockJira.secrets, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]', - }); - }); - }); - it('should respond with a 400 Bad Request when creating a jira action with empty mapping', async () => { await supertest .post('/api/actions/action') @@ -251,7 +231,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - casesConfiguration: { mapping: [] }, + incidentConfiguration: { mapping: [] }, }, secrets: mockJira.secrets, }) @@ -261,7 +241,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty', + 'error validating action type config: [incidentConfiguration.mapping]: expected non-empty but got empty', }); }); }); @@ -276,7 +256,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - casesConfiguration: { + incidentConfiguration: { mapping: [ { source: 'title', @@ -307,7 +287,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { config: { apiUrl: jiraSimulatorURL, projectKey: mockJira.config.projectKey, - casesConfiguration: mockJira.config.casesConfiguration, + incidentConfiguration: mockJira.config.incidentConfiguration, }, secrets: mockJira.secrets, }); @@ -353,7 +333,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', }); }); }); @@ -371,7 +351,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', }); }); }); @@ -389,7 +369,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', }); }); }); @@ -412,31 +392,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]', - }); - }); - }); - - it('should handle failing with a simulated success without createdAt', async () => { - await supertest - .post(`/api/actions/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockJira.params, - subActionParams: { - savedObjectId: 'success', - title: 'success', - }, - }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.createdAt]: expected value of type [string] but got [undefined]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', }); }); }); @@ -464,7 +420,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.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]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.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- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', }); }); }); @@ -492,35 +448,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.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]', - }); - }); - }); - - it('should handle failing with a simulated success without comment.createdAt', async () => { - await supertest - .post(`/api/actions/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockJira.params, - subActionParams: { - ...mockJira.params.subActionParams, - savedObjectId: 'success', - title: 'success', - createdAt: 'success', - createdBy: { username: 'elastic' }, - comments: [{ commentId: 'success', comment: 'success' }], - }, - }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.createdAt]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.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- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]', }); }); }); @@ -537,6 +465,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { subActionParams: { ...mockJira.params.subActionParams, comments: [], + issueType: '10006', }, }, }) 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 fb6f4fce3c29a..c23df53c4feef 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -66,7 +66,7 @@ export const getJiraConnector = () => ({ config: { apiUrl: 'http://some.non.existent.com', projectKey: 'pkey', - casesConfiguration: { + incidentConfiguration: { mapping: [ { source: 'title', @@ -85,6 +85,7 @@ export const getJiraConnector = () => ({ }, ], }, + isCaseOwned: true, }, });