mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Connectors] ServiceNow ITSM & SIR Application (#105440)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
396ed09259
commit
7ffebf1fa3
129 changed files with 5611 additions and 1312 deletions
|
@ -362,7 +362,8 @@ The plugin exposes the static DefaultEditorController class to consume.
|
|||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases]
|
||||
|Case management in Kibana
|
||||
|[![Issues][issues-shield]][issues-url]
|
||||
[![Pull Requests][pr-shield]][pr-url]
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud]
|
||||
|
|
|
@ -35,10 +35,14 @@ a| <<server-log-action-type, ServerLog>>
|
|||
|
||||
| Add a message to a Kibana log.
|
||||
|
||||
a| <<servicenow-action-type, ServiceNow>>
|
||||
a| <<servicenow-action-type, ServiceNow ITSM>>
|
||||
|
||||
| Create an incident in ServiceNow.
|
||||
|
||||
a| <<servicenow-sir-action-type, ServiceNow SecOps>>
|
||||
|
||||
| Create a security incident in ServiceNow.
|
||||
|
||||
a| <<slack-action-type, Slack>>
|
||||
|
||||
| Send a message to a Slack channel or user.
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
[role="xpack"]
|
||||
[[servicenow-sir-action-type]]
|
||||
=== ServiceNow connector and action
|
||||
++++
|
||||
<titleabbrev>ServiceNow SecOps</titleabbrev>
|
||||
++++
|
||||
|
||||
The ServiceNow SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow security incidents.
|
||||
|
||||
[float]
|
||||
[[servicenow-sir-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
ServiceNow SecOps connectors have the following configuration properties.
|
||||
|
||||
Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action.
|
||||
URL:: ServiceNow instance URL.
|
||||
Username:: Username for HTTP Basic authentication.
|
||||
Password:: Password for HTTP Basic authentication.
|
||||
|
||||
The ServiceNow user requires at minimum read, create, and update access to the Security Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render.
|
||||
|
||||
[float]
|
||||
[[servicenow-sir-connector-networking-configuration]]
|
||||
==== Connector networking configuration
|
||||
|
||||
Use the <<action-settings, Action configuration settings>> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.
|
||||
|
||||
[float]
|
||||
[[Preconfigured-servicenow-sir-configuration]]
|
||||
==== Preconfigured connector type
|
||||
|
||||
[source,text]
|
||||
--
|
||||
my-servicenow-sir:
|
||||
name: preconfigured-servicenow-connector-type
|
||||
actionTypeId: .servicenow-sir
|
||||
config:
|
||||
apiUrl: https://dev94428.service-now.com/
|
||||
secrets:
|
||||
username: testuser
|
||||
password: passwordkeystorevalue
|
||||
--
|
||||
|
||||
Config defines information for the connector type.
|
||||
|
||||
`apiUrl`:: An address that corresponds to *URL*.
|
||||
|
||||
Secrets defines sensitive information for the connector type.
|
||||
|
||||
`username`:: A string that corresponds to *Username*.
|
||||
`password`:: A string that corresponds to *Password*. Should be stored in the <<creating-keystore, {kib} keystore>>.
|
||||
|
||||
[float]
|
||||
[[define-servicenow-sir-ui]]
|
||||
==== Define connector in Stack Management
|
||||
|
||||
Define ServiceNow SecOps connector properties.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector]
|
||||
|
||||
Test ServiceNow SecOps action parameters.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow SecOps params test]
|
||||
|
||||
[float]
|
||||
[[servicenow-sir-action-configuration]]
|
||||
==== Action configuration
|
||||
|
||||
ServiceNow SecOps actions have the following configuration properties.
|
||||
|
||||
Short description:: A short description for the incident, used for searching the contents of the knowledge base.
|
||||
Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident.
|
||||
Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident.
|
||||
Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident.
|
||||
Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident.
|
||||
Priority:: The priority of the incident.
|
||||
Category:: The category of the incident.
|
||||
Subcategory:: The subcategory of the incident.
|
||||
Description:: The details about the incident.
|
||||
Additional comments:: Additional information for the client, such as how to troubleshoot the issue.
|
||||
|
||||
[float]
|
||||
[[configuring-servicenow-sir]]
|
||||
==== Configure ServiceNow SecOps
|
||||
|
||||
ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents.
|
|
@ -2,16 +2,16 @@
|
|||
[[servicenow-action-type]]
|
||||
=== ServiceNow connector and action
|
||||
++++
|
||||
<titleabbrev>ServiceNow</titleabbrev>
|
||||
<titleabbrev>ServiceNow ITSM</titleabbrev>
|
||||
++++
|
||||
|
||||
The ServiceNow connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents.
|
||||
The ServiceNow ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents.
|
||||
|
||||
[float]
|
||||
[[servicenow-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
ServiceNow connectors have the following configuration properties.
|
||||
ServiceNow ITSM connectors have the following configuration properties.
|
||||
|
||||
Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action.
|
||||
URL:: ServiceNow instance URL.
|
||||
|
@ -55,12 +55,12 @@ Secrets defines sensitive information for the connector type.
|
|||
[[define-servicenow-ui]]
|
||||
==== Define connector in Stack Management
|
||||
|
||||
Define ServiceNow connector properties.
|
||||
Define ServiceNow ITSM connector properties.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/servicenow-connector.png[ServiceNow connector]
|
||||
|
||||
Test ServiceNow action parameters.
|
||||
Test ServiceNow ITSM action parameters.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/servicenow-params-test.png[ServiceNow params test]
|
||||
|
@ -69,11 +69,13 @@ image::management/connectors/images/servicenow-params-test.png[ServiceNow params
|
|||
[[servicenow-action-configuration]]
|
||||
==== Action configuration
|
||||
|
||||
ServiceNow actions have the following configuration properties.
|
||||
ServiceNow ITSM actions have the following configuration properties.
|
||||
|
||||
Urgency:: The extent to which the incident resolution can delay.
|
||||
Severity:: The severity of the incident.
|
||||
Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question.
|
||||
Category:: The category of the incident.
|
||||
Subcategory:: The category of the incident.
|
||||
Short description:: A short description for the incident, used for searching the contents of the knowledge base.
|
||||
Description:: The details about the incident.
|
||||
Additional comments:: Additional information for the client, such as how to troubleshoot the issue.
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 46 KiB |
|
@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[]
|
|||
include::action-types/pagerduty.asciidoc[]
|
||||
include::action-types/server-log.asciidoc[]
|
||||
include::action-types/servicenow.asciidoc[]
|
||||
include::action-types/servicenow-sir.asciidoc[]
|
||||
include::action-types/swimlane.asciidoc[]
|
||||
include::action-types/slack.asciidoc[]
|
||||
include::action-types/webhook.asciidoc[]
|
||||
|
|
|
@ -33,29 +33,36 @@ Table of Contents
|
|||
- [actionsClient.execute(options)](#actionsclientexecuteoptions)
|
||||
- [Example](#example-2)
|
||||
- [Built-in Action Types](#built-in-action-types)
|
||||
- [ServiceNow](#servicenow)
|
||||
- [ServiceNow ITSM](#servicenow-itsm)
|
||||
- [`params`](#params)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields)
|
||||
- [`subActionParams (getIncident)`](#subactionparams-getincident)
|
||||
- [`subActionParams (getChoices)`](#subactionparams-getchoices)
|
||||
- [Jira](#jira)
|
||||
- [ServiceNow Sec Ops](#servicenow-sec-ops)
|
||||
- [`params`](#params-1)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields-1)
|
||||
- [`subActionParams (getIncident)`](#subactionparams-getincident-1)
|
||||
- [`subActionParams (getChoices)`](#subactionparams-getchoices-1)
|
||||
- [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-)
|
||||
- [Jira](#jira)
|
||||
- [`params`](#params-2)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
|
||||
- [`subActionParams (getIncident)`](#subactionparams-getincident-2)
|
||||
- [`subActionParams (issueTypes)`](#subactionparams-issuetypes)
|
||||
- [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype)
|
||||
- [`subActionParams (issues)`](#subactionparams-issues)
|
||||
- [`subActionParams (issue)`](#subactionparams-issue)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields-1)
|
||||
- [IBM Resilient](#ibm-resilient)
|
||||
- [`params`](#params-2)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
|
||||
- [IBM Resilient](#ibm-resilient)
|
||||
- [`params`](#params-3)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields-3)
|
||||
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
|
||||
- [`subActionParams (severity)`](#subactionparams-severity)
|
||||
- [Swimlane](#swimlane)
|
||||
- [`params`](#params-3)
|
||||
- [`params`](#params-4)
|
||||
- [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-)
|
||||
- [Command Line Utility](#command-line-utility)
|
||||
- [Developing New Action Types](#developing-new-action-types)
|
||||
|
@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ
|
|||
|
||||
In addition to the documented configurations, several built in action type offer additional `params` configurations.
|
||||
|
||||
## ServiceNow
|
||||
## ServiceNow ITSM
|
||||
|
||||
The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
|
||||
The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
|
||||
### `params`
|
||||
|
||||
| Property | Description | Type |
|
||||
|
@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib
|
|||
|
||||
The following table describes the properties of the `incident` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| short_description | The title of the incident. | string |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
|
||||
| severity | The severity in ServiceNow. | string _(optional)_ |
|
||||
| urgency | The urgency in ServiceNow. | string _(optional)_ |
|
||||
| impact | The impact in ServiceNow. | string _(optional)_ |
|
||||
| category | The category in ServiceNow. | string _(optional)_ |
|
||||
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
|
||||
| Property | Description | Type |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| short_description | The title of the incident. | string |
|
||||
| description | The description of the incident. | string _(optional)_ |
|
||||
| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
|
||||
| severity | The severity in ServiceNow. | string _(optional)_ |
|
||||
| urgency | The urgency in ServiceNow. | string _(optional)_ |
|
||||
| impact | The impact in ServiceNow. | string _(optional)_ |
|
||||
| category | The category in ServiceNow. | string _(optional)_ |
|
||||
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
|
||||
| correlation_id | The correlation id of the incident. | string _(optional)_ |
|
||||
| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ |
|
||||
|
||||
#### `subActionParams (getFields)`
|
||||
|
||||
|
@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`.
|
|||
|
||||
#### `subActionParams (getChoices)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | ------------------------------------------------------------ | -------- |
|
||||
| fields | An array of fields. Example: `[priority, category, impact]`. | string[] |
|
||||
| Property | Description | Type |
|
||||
| -------- | -------------------------------------------------- | -------- |
|
||||
| fields | An array of fields. Example: `[category, impact]`. | string[] |
|
||||
|
||||
---
|
||||
|
||||
## ServiceNow Sec Ops
|
||||
|
||||
The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
|
||||
|
||||
### `params`
|
||||
|
||||
| Property | Description | Type |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- | ------ |
|
||||
| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string |
|
||||
| subActionParams | The parameters of the subaction. | object |
|
||||
|
||||
#### `subActionParams (pushToService)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| incident | The ServiceNow security incident. | object |
|
||||
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ |
|
||||
|
||||
The following table describes the properties of the `incident` object.
|
||||
|
||||
| Property | Description | Type |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| short_description | The title of the security incident. | string |
|
||||
| description | The description of the security incident. | string _(optional)_ |
|
||||
| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ |
|
||||
| priority | The priority in ServiceNow. | string _(optional)_ |
|
||||
| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
|
||||
| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
|
||||
| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
|
||||
| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
|
||||
| category | The category in ServiceNow. | string _(optional)_ |
|
||||
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
|
||||
| correlation_id | The correlation id of the security incident. | string _(optional)_ |
|
||||
| correlation_display | The correlation display of the security incident. | string _(optional)_ |
|
||||
|
||||
#### `subActionParams (getFields)`
|
||||
|
||||
No parameters for the `getFields` subaction. Provide an empty object `{}`.
|
||||
|
||||
#### `subActionParams (getIncident)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| ---------- | ---------------------------------------------- | ------ |
|
||||
| externalId | The ID of the security incident in ServiceNow. | string |
|
||||
|
||||
|
||||
#### `subActionParams (getChoices)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | ---------------------------------------------------- | -------- |
|
||||
| fields | An array of fields. Example: `[priority, category]`. | string[] |
|
||||
---
|
||||
## Jira
|
||||
|
||||
The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
|
||||
|
|
|
@ -143,7 +143,7 @@ export function getActionType({
|
|||
}),
|
||||
validate: {
|
||||
config: schema.object(configSchemaProps, {
|
||||
validate: curry(valdiateActionTypeConfig)(configurationUtilities),
|
||||
validate: curry(validateActionTypeConfig)(configurationUtilities),
|
||||
}),
|
||||
secrets: SecretsSchema,
|
||||
params: ParamsSchema,
|
||||
|
@ -152,7 +152,7 @@ export function getActionType({
|
|||
};
|
||||
}
|
||||
|
||||
function valdiateActionTypeConfig(
|
||||
function validateActionTypeConfig(
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
configObject: ActionTypeConfigType
|
||||
) {
|
||||
|
|
|
@ -25,6 +25,7 @@ describe('api', () => {
|
|||
const res = await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -57,6 +58,7 @@ describe('api', () => {
|
|||
const res = await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -78,6 +80,7 @@ describe('api', () => {
|
|||
await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: { username: 'elastic', password: 'elastic' },
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -93,6 +96,9 @@ describe('api', () => {
|
|||
caller_id: 'elastic',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
opened_by: 'elastic',
|
||||
},
|
||||
});
|
||||
expect(externalService.updateIncident).not.toHaveBeenCalled();
|
||||
|
@ -103,6 +109,7 @@ describe('api', () => {
|
|||
await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -118,6 +125,8 @@ describe('api', () => {
|
|||
comments: 'A comment',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-1',
|
||||
});
|
||||
|
@ -132,6 +141,8 @@ describe('api', () => {
|
|||
comments: 'Another comment',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-1',
|
||||
});
|
||||
|
@ -142,6 +153,7 @@ describe('api', () => {
|
|||
await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'work_notes',
|
||||
|
@ -157,6 +169,8 @@ describe('api', () => {
|
|||
work_notes: 'A comment',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-1',
|
||||
});
|
||||
|
@ -171,6 +185,8 @@ describe('api', () => {
|
|||
work_notes: 'Another comment',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-1',
|
||||
});
|
||||
|
@ -182,6 +198,7 @@ describe('api', () => {
|
|||
const res = await api.pushToService({
|
||||
externalService,
|
||||
params: apiParams,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -210,6 +227,7 @@ describe('api', () => {
|
|||
const res = await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -228,6 +246,7 @@ describe('api', () => {
|
|||
await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -243,6 +262,8 @@ describe('api', () => {
|
|||
subcategory: 'os',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
});
|
||||
expect(externalService.createIncident).not.toHaveBeenCalled();
|
||||
|
@ -253,6 +274,7 @@ describe('api', () => {
|
|||
await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'comments',
|
||||
|
@ -267,6 +289,8 @@ describe('api', () => {
|
|||
subcategory: 'os',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-3',
|
||||
});
|
||||
|
@ -281,6 +305,8 @@ describe('api', () => {
|
|||
comments: 'A comment',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-2',
|
||||
});
|
||||
|
@ -291,6 +317,7 @@ describe('api', () => {
|
|||
await api.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: {},
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'work_notes',
|
||||
|
@ -305,6 +332,8 @@ describe('api', () => {
|
|||
subcategory: 'os',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-3',
|
||||
});
|
||||
|
@ -319,6 +348,8 @@ describe('api', () => {
|
|||
work_notes: 'A comment',
|
||||
description: 'Incident description',
|
||||
short_description: 'Incident title',
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: 'ruleId',
|
||||
},
|
||||
incidentId: 'incident-2',
|
||||
});
|
||||
|
@ -344,4 +375,23 @@ describe('api', () => {
|
|||
expect(res).toEqual(serviceNowChoices);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncident', () => {
|
||||
test('it gets the incident correctly', async () => {
|
||||
const res = await api.getIncident({
|
||||
externalService,
|
||||
params: {
|
||||
externalId: 'incident-1',
|
||||
},
|
||||
});
|
||||
expect(res).toEqual({
|
||||
description: 'description from servicenow',
|
||||
id: 'incident-1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
short_description: 'title from servicenow',
|
||||
title: 'INC01',
|
||||
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
ExternalServiceApi,
|
||||
ExternalServiceAPI,
|
||||
GetChoicesHandlerArgs,
|
||||
GetChoicesResponse,
|
||||
GetCommonFieldsHandlerArgs,
|
||||
|
@ -19,7 +19,11 @@ import {
|
|||
} from './types';
|
||||
|
||||
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
|
||||
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {};
|
||||
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {
|
||||
const { externalId: id } = params;
|
||||
const res = await externalService.getIncident(id);
|
||||
return res;
|
||||
};
|
||||
|
||||
const pushToServiceHandler = async ({
|
||||
externalService,
|
||||
|
@ -42,6 +46,7 @@ const pushToServiceHandler = async ({
|
|||
incident: {
|
||||
...incident,
|
||||
caller_id: secrets.username,
|
||||
opened_by: secrets.username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -84,7 +89,7 @@ const getChoicesHandler = async ({
|
|||
return res;
|
||||
};
|
||||
|
||||
export const api: ExternalServiceApi = {
|
||||
export const api: ExternalServiceAPI = {
|
||||
getChoices: getChoicesHandler,
|
||||
getFields: getFieldsHandler,
|
||||
getIncident: getIncidentHandler,
|
||||
|
|
|
@ -0,0 +1,286 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { externalServiceSIRMock, sirParams } from './mocks';
|
||||
import { ExternalServiceSIR, ObservableTypes } from './types';
|
||||
import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir';
|
||||
let mockedLogger: jest.Mocked<Logger>;
|
||||
|
||||
describe('api_sir', () => {
|
||||
let externalService: jest.Mocked<ExternalServiceSIR>;
|
||||
|
||||
beforeEach(() => {
|
||||
externalService = externalServiceSIRMock.create();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('combineObservables', () => {
|
||||
test('it returns an empty array when both arguments are an empty array', async () => {
|
||||
expect(combineObservables([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
test('it returns an empty array when both arguments are an empty string', async () => {
|
||||
expect(combineObservables('', '')).toEqual([]);
|
||||
});
|
||||
|
||||
test('it returns an empty array when a="" and b=[]', async () => {
|
||||
expect(combineObservables('', [])).toEqual([]);
|
||||
});
|
||||
|
||||
test('it returns an empty array when a=[] and b=""', async () => {
|
||||
expect(combineObservables([], '')).toEqual([]);
|
||||
});
|
||||
|
||||
test('it returns a if b is empty', async () => {
|
||||
expect(combineObservables('a', '')).toEqual(['a']);
|
||||
});
|
||||
|
||||
test('it returns b if a is empty', async () => {
|
||||
expect(combineObservables([], ['b'])).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('it combines two strings', async () => {
|
||||
expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('it combines two arrays', async () => {
|
||||
expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('it combines a string with an array', async () => {
|
||||
expect(combineObservables('a', ['b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('it combines an array with a string ', async () => {
|
||||
expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('it combines a "," concatenated string', async () => {
|
||||
expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
test('it combines a "|" concatenated string', async () => {
|
||||
expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
test('it combines a space concatenated string', async () => {
|
||||
expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
test('it combines a "\\n" concatenated string', async () => {
|
||||
expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
test('it combines a "\\r" concatenated string', async () => {
|
||||
expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
test('it combines a "\\t" concatenated string', async () => {
|
||||
expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
test('it combines two strings with different delimiter', async () => {
|
||||
expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatObservables', () => {
|
||||
test('it formats array observables correctly', async () => {
|
||||
const expectedTypes: Array<[ObservableTypes, string]> = [
|
||||
[ObservableTypes.ip4, 'ipv4-addr'],
|
||||
[ObservableTypes.sha256, 'SHA256'],
|
||||
[ObservableTypes.url, 'URL'],
|
||||
];
|
||||
|
||||
for (const type of expectedTypes) {
|
||||
expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([
|
||||
{ type: type[1], value: 'a' },
|
||||
{ type: type[1], value: 'b' },
|
||||
{ type: type[1], value: 'c' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('it removes duplicates from array observables correctly', async () => {
|
||||
expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([
|
||||
{ type: 'ipv4-addr', value: 'a' },
|
||||
{ type: 'ipv4-addr', value: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('it formats an empty array correctly', async () => {
|
||||
expect(formatObservables([], ObservableTypes.ip4)).toEqual([]);
|
||||
});
|
||||
|
||||
test('it removes empty observables correctly', async () => {
|
||||
expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([
|
||||
{ type: 'ipv4-addr', value: 'a' },
|
||||
{ type: 'ipv4-addr', value: 'c' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareParams', () => {
|
||||
test('it prepares the params correctly when the connector is legacy', async () => {
|
||||
expect(prepareParams(true, sirParams)).toEqual({
|
||||
...sirParams,
|
||||
incident: {
|
||||
...sirParams.incident,
|
||||
dest_ip: '192.168.1.1,192.168.1.3',
|
||||
source_ip: '192.168.1.2,192.168.1.4',
|
||||
malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
|
||||
malware_url: 'https://example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it prepares the params correctly when the connector is not legacy', async () => {
|
||||
expect(prepareParams(false, sirParams)).toEqual({
|
||||
...sirParams,
|
||||
incident: {
|
||||
...sirParams.incident,
|
||||
dest_ip: null,
|
||||
source_ip: null,
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => {
|
||||
const {
|
||||
dest_ip: destIp,
|
||||
source_ip: sourceIp,
|
||||
malware_hash: malwareHash,
|
||||
malware_url: malwareURL,
|
||||
...incidentWithoutObservables
|
||||
} = sirParams.incident;
|
||||
|
||||
expect(
|
||||
prepareParams(true, {
|
||||
...sirParams,
|
||||
// @ts-expect-error
|
||||
incident: incidentWithoutObservables,
|
||||
})
|
||||
).toEqual({
|
||||
...sirParams,
|
||||
incident: {
|
||||
...sirParams.incident,
|
||||
dest_ip: null,
|
||||
source_ip: null,
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushToService', () => {
|
||||
test('it creates an incident correctly', async () => {
|
||||
const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
|
||||
const res = await apiSIR.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: { isLegacy: false },
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
id: 'incident-1',
|
||||
title: 'INC01',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
|
||||
comments: [
|
||||
{
|
||||
commentId: 'case-comment-1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
},
|
||||
{
|
||||
commentId: 'case-comment-2',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('it adds observables correctly', async () => {
|
||||
const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
|
||||
await apiSIR.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: { isLegacy: false },
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
|
||||
expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith(
|
||||
[
|
||||
{ type: 'ipv4-addr', value: '192.168.1.1' },
|
||||
{ type: 'ipv4-addr', value: '192.168.1.3' },
|
||||
{ type: 'ipv4-addr', value: '192.168.1.2' },
|
||||
{ type: 'ipv4-addr', value: '192.168.1.4' },
|
||||
{
|
||||
type: 'SHA256',
|
||||
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
|
||||
},
|
||||
{ type: 'URL', value: 'https://example.com' },
|
||||
],
|
||||
// createIncident mock returns this incident id
|
||||
'incident-1'
|
||||
);
|
||||
});
|
||||
|
||||
test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => {
|
||||
const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
|
||||
await apiSIR.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: { isLegacy: true },
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
|
||||
expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it does not call bulkAddObservableToIncident if there are no observables', async () => {
|
||||
const params = {
|
||||
...sirParams,
|
||||
incident: {
|
||||
...sirParams.incident,
|
||||
dest_ip: null,
|
||||
source_ip: null,
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
externalId: null,
|
||||
},
|
||||
};
|
||||
|
||||
await apiSIR.pushToService({
|
||||
externalService,
|
||||
params,
|
||||
config: { isLegacy: false },
|
||||
secrets: {},
|
||||
logger: mockedLogger,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
|
||||
expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty, isString } from 'lodash';
|
||||
|
||||
import {
|
||||
ExecutorSubActionPushParamsSIR,
|
||||
ExternalServiceAPI,
|
||||
ExternalServiceSIR,
|
||||
ObservableTypes,
|
||||
PushToServiceApiHandlerArgs,
|
||||
PushToServiceApiParamsSIR,
|
||||
PushToServiceResponse,
|
||||
} from './types';
|
||||
|
||||
import { api } from './api';
|
||||
|
||||
const SPLIT_REGEX = /[ ,|\r\n\t]+/;
|
||||
|
||||
export const formatObservables = (observables: string[], type: ObservableTypes) => {
|
||||
/**
|
||||
* ServiceNow accepted formats are: comma, new line, tab, or pipe separators.
|
||||
* Before the application the observables were being sent to ServiceNow as a concatenated string with
|
||||
* delimiter. With the application the format changed to an array of observables.
|
||||
*/
|
||||
const uniqueObservables = new Set(observables);
|
||||
return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type }));
|
||||
};
|
||||
|
||||
const obsAsArray = (obs: string | string[]): string[] => {
|
||||
if (isEmpty(obs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isString(obs)) {
|
||||
return obs.split(SPLIT_REGEX);
|
||||
}
|
||||
|
||||
return obs;
|
||||
};
|
||||
|
||||
export const combineObservables = (a: string | string[], b: string | string[]): string[] => {
|
||||
const first = obsAsArray(a);
|
||||
const second = obsAsArray(b);
|
||||
|
||||
return [...first, ...second];
|
||||
};
|
||||
|
||||
const observablesToString = (obs: string | string[] | null | undefined): string | null => {
|
||||
if (Array.isArray(obs)) {
|
||||
return obs.join(',');
|
||||
}
|
||||
|
||||
return obs ?? null;
|
||||
};
|
||||
|
||||
export const prepareParams = (
|
||||
isLegacy: boolean,
|
||||
params: PushToServiceApiParamsSIR
|
||||
): PushToServiceApiParamsSIR => {
|
||||
if (isLegacy) {
|
||||
/**
|
||||
* The schema has change to accept an array of observables
|
||||
* or a string. In the case of a legacy connector we need to
|
||||
* convert the observables to a string
|
||||
*/
|
||||
return {
|
||||
...params,
|
||||
incident: {
|
||||
...params.incident,
|
||||
dest_ip: observablesToString(params.incident.dest_ip),
|
||||
malware_hash: observablesToString(params.incident.malware_hash),
|
||||
malware_url: observablesToString(params.incident.malware_url),
|
||||
source_ip: observablesToString(params.incident.source_ip),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* For non legacy connectors the observables
|
||||
* will be added in a different call.
|
||||
* They need to be set to null when sending the fields
|
||||
* to ServiceNow
|
||||
*/
|
||||
return {
|
||||
...params,
|
||||
incident: {
|
||||
...params.incident,
|
||||
dest_ip: null,
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
source_ip: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pushToServiceHandler = async ({
|
||||
externalService,
|
||||
params,
|
||||
config,
|
||||
secrets,
|
||||
commentFieldKey,
|
||||
logger,
|
||||
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
|
||||
const res = await api.pushToService({
|
||||
externalService,
|
||||
params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR),
|
||||
config,
|
||||
secrets,
|
||||
commentFieldKey,
|
||||
logger,
|
||||
});
|
||||
|
||||
const {
|
||||
incident: {
|
||||
dest_ip: destIP,
|
||||
malware_hash: malwareHash,
|
||||
malware_url: malwareUrl,
|
||||
source_ip: sourceIP,
|
||||
},
|
||||
} = params as ExecutorSubActionPushParamsSIR;
|
||||
|
||||
/**
|
||||
* Add bulk observables is only available for new connectors
|
||||
* Old connectors gonna add their observables
|
||||
* through the pushToService call.
|
||||
*/
|
||||
|
||||
if (!config.isLegacy) {
|
||||
const sirExternalService = externalService as ExternalServiceSIR;
|
||||
|
||||
const obsWithType: Array<[string[], ObservableTypes]> = [
|
||||
[combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4],
|
||||
[obsAsArray(malwareHash ?? []), ObservableTypes.sha256],
|
||||
[obsAsArray(malwareUrl ?? []), ObservableTypes.url],
|
||||
];
|
||||
|
||||
const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat();
|
||||
if (observables.length > 0) {
|
||||
await sirExternalService.bulkAddObservableToIncident(observables, res.id);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const apiSIR: ExternalServiceAPI = {
|
||||
...api,
|
||||
pushToService: pushToServiceHandler,
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { snExternalServiceConfig } from './config';
|
||||
|
||||
/**
|
||||
* The purpose of this test is to
|
||||
* prevent developers from accidentally
|
||||
* change important configuration values
|
||||
* such as the scope or the import set table
|
||||
* of our ServiceNow application
|
||||
*/
|
||||
|
||||
describe('config', () => {
|
||||
test('ITSM: the config are correct', async () => {
|
||||
const snConfig = snExternalServiceConfig['.servicenow'];
|
||||
expect(snConfig).toEqual({
|
||||
importSetTable: 'x_elas2_inc_int_elastic_incident',
|
||||
appScope: 'x_elas2_inc_int',
|
||||
table: 'incident',
|
||||
useImportAPI: true,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
});
|
||||
|
||||
test('SIR: the config are correct', async () => {
|
||||
const snConfig = snExternalServiceConfig['.servicenow-sir'];
|
||||
expect(snConfig).toEqual({
|
||||
importSetTable: 'x_elas2_sir_int_elastic_si_incident',
|
||||
appScope: 'x_elas2_sir_int',
|
||||
table: 'sn_si_incident',
|
||||
useImportAPI: true,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
ENABLE_NEW_SN_ITSM_CONNECTOR,
|
||||
ENABLE_NEW_SN_SIR_CONNECTOR,
|
||||
} from '../../constants/connectors';
|
||||
import { SNProductsConfig } from './types';
|
||||
|
||||
export const serviceNowITSMTable = 'incident';
|
||||
export const serviceNowSIRTable = 'sn_si_incident';
|
||||
|
||||
export const ServiceNowITSMActionTypeId = '.servicenow';
|
||||
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
|
||||
|
||||
export const snExternalServiceConfig: SNProductsConfig = {
|
||||
'.servicenow': {
|
||||
importSetTable: 'x_elas2_inc_int_elastic_incident',
|
||||
appScope: 'x_elas2_inc_int',
|
||||
table: 'incident',
|
||||
useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR,
|
||||
commentFieldKey: 'work_notes',
|
||||
},
|
||||
'.servicenow-sir': {
|
||||
importSetTable: 'x_elas2_sir_int_elastic_si_incident',
|
||||
appScope: 'x_elas2_sir_int',
|
||||
table: 'sn_si_incident',
|
||||
useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR,
|
||||
commentFieldKey: 'work_notes',
|
||||
},
|
||||
};
|
||||
|
||||
export const FIELD_PREFIX = 'u_';
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
|
||||
import { createExternalService } from './service';
|
||||
import { api } from './api';
|
||||
import { api as commonAPI } from './api';
|
||||
import * as i18n from './translations';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import {
|
||||
|
@ -30,7 +30,25 @@ import {
|
|||
ExecutorSubActionCommonFieldsParams,
|
||||
ServiceNowExecutorResultData,
|
||||
ExecutorSubActionGetChoicesParams,
|
||||
ServiceFactory,
|
||||
ExternalServiceAPI,
|
||||
} from './types';
|
||||
import {
|
||||
ServiceNowITSMActionTypeId,
|
||||
serviceNowITSMTable,
|
||||
ServiceNowSIRActionTypeId,
|
||||
serviceNowSIRTable,
|
||||
snExternalServiceConfig,
|
||||
} from './config';
|
||||
import { createExternalServiceSIR } from './service_sir';
|
||||
import { apiSIR } from './api_sir';
|
||||
|
||||
export {
|
||||
ServiceNowITSMActionTypeId,
|
||||
serviceNowITSMTable,
|
||||
ServiceNowSIRActionTypeId,
|
||||
serviceNowSIRTable,
|
||||
};
|
||||
|
||||
export type ActionParamsType =
|
||||
| TypeOf<typeof ExecutorParamsSchemaITSM>
|
||||
|
@ -41,12 +59,6 @@ interface GetActionTypeParams {
|
|||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
}
|
||||
|
||||
const serviceNowITSMTable = 'incident';
|
||||
const serviceNowSIRTable = 'sn_si_incident';
|
||||
|
||||
export const ServiceNowITSMActionTypeId = '.servicenow';
|
||||
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
|
||||
|
||||
export type ServiceNowActionType = ActionType<
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
|
@ -79,8 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic
|
|||
executor: curry(executor)({
|
||||
logger,
|
||||
configurationUtilities,
|
||||
table: serviceNowITSMTable,
|
||||
commentFieldKey: 'work_notes',
|
||||
actionTypeId: ServiceNowITSMActionTypeId,
|
||||
createService: createExternalService,
|
||||
api: commonAPI,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -103,8 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service
|
|||
executor: curry(executor)({
|
||||
logger,
|
||||
configurationUtilities,
|
||||
table: serviceNowSIRTable,
|
||||
commentFieldKey: 'work_notes',
|
||||
actionTypeId: ServiceNowSIRActionTypeId,
|
||||
createService: createExternalServiceSIR,
|
||||
api: apiSIR,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -115,28 +129,31 @@ async function executor(
|
|||
{
|
||||
logger,
|
||||
configurationUtilities,
|
||||
table,
|
||||
commentFieldKey = 'comments',
|
||||
actionTypeId,
|
||||
createService,
|
||||
api,
|
||||
}: {
|
||||
logger: Logger;
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
table: string;
|
||||
commentFieldKey?: string;
|
||||
actionTypeId: string;
|
||||
createService: ServiceFactory;
|
||||
api: ExternalServiceAPI;
|
||||
},
|
||||
execOptions: ServiceNowActionTypeExecutorOptions
|
||||
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
|
||||
const { actionId, config, params, secrets } = execOptions;
|
||||
const { subAction, subActionParams } = params;
|
||||
const externalServiceConfig = snExternalServiceConfig[actionTypeId];
|
||||
let data: ServiceNowExecutorResultData | null = null;
|
||||
|
||||
const externalService = createExternalService(
|
||||
table,
|
||||
const externalService = createService(
|
||||
{
|
||||
config,
|
||||
secrets,
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
externalServiceConfig
|
||||
);
|
||||
|
||||
if (!api[subAction]) {
|
||||
|
@ -156,9 +173,10 @@ async function executor(
|
|||
data = await api.pushToService({
|
||||
externalService,
|
||||
params: pushToServiceParams,
|
||||
config,
|
||||
secrets,
|
||||
logger,
|
||||
commentFieldKey,
|
||||
commentFieldKey: externalServiceConfig.commentFieldKey,
|
||||
});
|
||||
|
||||
logger.debug(`response push to service for incident id: ${data.id}`);
|
||||
|
|
|
@ -5,7 +5,14 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ExternalService, ExecutorSubActionPushParams } from './types';
|
||||
import {
|
||||
ExternalService,
|
||||
ExecutorSubActionPushParams,
|
||||
PushToServiceApiParamsSIR,
|
||||
ExternalServiceSIR,
|
||||
Observable,
|
||||
ObservableTypes,
|
||||
} from './types';
|
||||
|
||||
export const serviceNowCommonFields = [
|
||||
{
|
||||
|
@ -74,6 +81,10 @@ const createMock = (): jest.Mocked<ExternalService> => {
|
|||
getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)),
|
||||
getIncident: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
id: 'incident-1',
|
||||
title: 'INC01',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
|
||||
short_description: 'title from servicenow',
|
||||
description: 'description from servicenow',
|
||||
})
|
||||
|
@ -95,16 +106,60 @@ const createMock = (): jest.Mocked<ExternalService> => {
|
|||
})
|
||||
),
|
||||
findIncidents: jest.fn(),
|
||||
getApplicationInformation: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
name: 'Elastic',
|
||||
scope: 'x_elas2_inc_int',
|
||||
version: '1.0.0',
|
||||
})
|
||||
),
|
||||
checkIfApplicationIsInstalled: jest.fn(),
|
||||
getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'),
|
||||
checkInstance: jest.fn(),
|
||||
};
|
||||
|
||||
return service;
|
||||
};
|
||||
|
||||
const externalServiceMock = {
|
||||
const createSIRMock = (): jest.Mocked<ExternalServiceSIR> => {
|
||||
const service = {
|
||||
...createMock(),
|
||||
addObservableToIncident: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
value: 'https://example.com',
|
||||
observable_sys_id: '3',
|
||||
})
|
||||
),
|
||||
bulkAddObservableToIncident: jest.fn().mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
|
||||
observable_sys_id: '1',
|
||||
},
|
||||
{
|
||||
value: '127.0.0.1',
|
||||
observable_sys_id: '2',
|
||||
},
|
||||
{
|
||||
value: 'https://example.com',
|
||||
observable_sys_id: '3',
|
||||
},
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
return service;
|
||||
};
|
||||
|
||||
export const externalServiceMock = {
|
||||
create: createMock,
|
||||
};
|
||||
|
||||
const executorParams: ExecutorSubActionPushParams = {
|
||||
export const externalServiceSIRMock = {
|
||||
create: createSIRMock,
|
||||
};
|
||||
|
||||
export const executorParams: ExecutorSubActionPushParams = {
|
||||
incident: {
|
||||
externalId: 'incident-3',
|
||||
short_description: 'Incident title',
|
||||
|
@ -114,6 +169,8 @@ const executorParams: ExecutorSubActionPushParams = {
|
|||
impact: '3',
|
||||
category: 'software',
|
||||
subcategory: 'os',
|
||||
correlation_id: 'ruleId',
|
||||
correlation_display: 'Alerting',
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
|
@ -127,6 +184,46 @@ const executorParams: ExecutorSubActionPushParams = {
|
|||
],
|
||||
};
|
||||
|
||||
const apiParams = executorParams;
|
||||
export const sirParams: PushToServiceApiParamsSIR = {
|
||||
incident: {
|
||||
externalId: 'incident-3',
|
||||
short_description: 'Incident title',
|
||||
description: 'Incident description',
|
||||
dest_ip: ['192.168.1.1', '192.168.1.3'],
|
||||
source_ip: ['192.168.1.2', '192.168.1.4'],
|
||||
malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'],
|
||||
malware_url: ['https://example.com'],
|
||||
category: 'software',
|
||||
subcategory: 'os',
|
||||
correlation_id: 'ruleId',
|
||||
correlation_display: 'Alerting',
|
||||
priority: '1',
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
commentId: 'case-comment-1',
|
||||
comment: 'A comment',
|
||||
},
|
||||
{
|
||||
commentId: 'case-comment-2',
|
||||
comment: 'Another comment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export { externalServiceMock, executorParams, apiParams };
|
||||
export const observables: Observable[] = [
|
||||
{
|
||||
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
|
||||
type: ObservableTypes.sha256,
|
||||
},
|
||||
{
|
||||
value: '127.0.0.1',
|
||||
type: ObservableTypes.ip4,
|
||||
},
|
||||
{
|
||||
value: 'https://example.com',
|
||||
type: ObservableTypes.url,
|
||||
},
|
||||
];
|
||||
|
||||
export const apiParams = executorParams;
|
||||
|
|
|
@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
|
|||
|
||||
export const ExternalIncidentServiceConfiguration = {
|
||||
apiUrl: schema.string(),
|
||||
isLegacy: schema.boolean({ defaultValue: false }),
|
||||
};
|
||||
|
||||
export const ExternalIncidentServiceConfigurationSchema = schema.object(
|
||||
|
@ -39,6 +40,8 @@ const CommonAttributes = {
|
|||
externalId: schema.nullable(schema.string()),
|
||||
category: schema.nullable(schema.string()),
|
||||
subcategory: schema.nullable(schema.string()),
|
||||
correlation_id: schema.nullable(schema.string()),
|
||||
correlation_display: schema.nullable(schema.string()),
|
||||
};
|
||||
|
||||
// Schema for ServiceNow Incident Management (ITSM)
|
||||
|
@ -56,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({
|
|||
export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
|
||||
incident: schema.object({
|
||||
...CommonAttributes,
|
||||
dest_ip: schema.nullable(schema.string()),
|
||||
malware_hash: schema.nullable(schema.string()),
|
||||
malware_url: schema.nullable(schema.string()),
|
||||
source_ip: schema.nullable(schema.string()),
|
||||
dest_ip: schema.oneOf(
|
||||
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
|
||||
{ defaultValue: null }
|
||||
),
|
||||
malware_hash: schema.oneOf(
|
||||
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
|
||||
{ defaultValue: null }
|
||||
),
|
||||
malware_url: schema.oneOf(
|
||||
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
|
||||
{ defaultValue: null }
|
||||
),
|
||||
source_ip: schema.oneOf(
|
||||
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
|
||||
{ defaultValue: null }
|
||||
),
|
||||
priority: schema.nullable(schema.string()),
|
||||
}),
|
||||
comments: CommentsSchema,
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
|
||||
import { createExternalService } from './service';
|
||||
import * as utils from '../lib/axios_utils';
|
||||
import { ExternalService } from './types';
|
||||
import { ExternalService, ServiceNowITSMIncident } from './types';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
import { serviceNowCommonFields, serviceNowChoices } from './mocks';
|
||||
import { snExternalServiceConfig } from './config';
|
||||
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
||||
|
||||
jest.mock('axios');
|
||||
|
@ -28,24 +29,134 @@ jest.mock('../lib/axios_utils', () => {
|
|||
|
||||
axios.create = jest.fn(() => axios);
|
||||
const requestMock = utils.request as jest.Mock;
|
||||
const patchMock = utils.patch as jest.Mock;
|
||||
const configurationUtilities = actionsConfigMock.create();
|
||||
const table = 'incident';
|
||||
|
||||
const getImportSetAPIResponse = (update = false) => ({
|
||||
import_set: 'ISET01',
|
||||
staging_table: 'x_elas2_inc_int_elastic_incident',
|
||||
result: [
|
||||
{
|
||||
transform_map: 'Elastic Incident',
|
||||
table: 'incident',
|
||||
display_name: 'number',
|
||||
display_value: 'INC01',
|
||||
record_link: 'https://example.com/api/now/table/incident/1',
|
||||
status: update ? 'updated' : 'inserted',
|
||||
sys_id: '1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const getImportSetAPIError = () => ({
|
||||
import_set: 'ISET01',
|
||||
staging_table: 'x_elas2_inc_int_elastic_incident',
|
||||
result: [
|
||||
{
|
||||
transform_map: 'Elastic Incident',
|
||||
status: 'error',
|
||||
error_message: 'An error has occurred while importing the incident',
|
||||
status_message: 'failure',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mockApplicationVersion = () =>
|
||||
requestMock.mockImplementationOnce(() => ({
|
||||
data: {
|
||||
result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockImportIncident = (update: boolean) =>
|
||||
requestMock.mockImplementationOnce(() => ({
|
||||
data: getImportSetAPIResponse(update),
|
||||
}));
|
||||
|
||||
const mockIncidentResponse = (update: boolean) =>
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: {
|
||||
result: {
|
||||
sys_id: '1',
|
||||
number: 'INC01',
|
||||
...(update
|
||||
? { sys_updated_on: '2020-03-10 12:24:20' }
|
||||
: { sys_created_on: '2020-03-10 12:24:20' }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const createIncident = async (service: ExternalService) => {
|
||||
// Get application version
|
||||
mockApplicationVersion();
|
||||
// Import set api response
|
||||
mockImportIncident(false);
|
||||
// Get incident response
|
||||
mockIncidentResponse(false);
|
||||
|
||||
return await service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
});
|
||||
};
|
||||
|
||||
const updateIncident = async (service: ExternalService) => {
|
||||
// Get application version
|
||||
mockApplicationVersion();
|
||||
// Import set api response
|
||||
mockImportIncident(true);
|
||||
// Get incident response
|
||||
mockIncidentResponse(true);
|
||||
|
||||
return await service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
});
|
||||
};
|
||||
|
||||
const expectImportedIncident = (update: boolean) => {
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(2, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident',
|
||||
method: 'post',
|
||||
data: {
|
||||
u_short_description: 'title',
|
||||
u_description: 'desc',
|
||||
...(update ? { elastic_incident_id: '1' } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(3, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/incident/1',
|
||||
method: 'get',
|
||||
});
|
||||
};
|
||||
|
||||
describe('ServiceNow service', () => {
|
||||
let service: ExternalService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = createExternalService(
|
||||
table,
|
||||
{
|
||||
// The trailing slash at the end of the url is intended.
|
||||
// All API calls need to have the trailing slash removed.
|
||||
config: { apiUrl: 'https://dev102283.service-now.com/' },
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow']
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -57,13 +168,13 @@ describe('ServiceNow service', () => {
|
|||
test('throws without url', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
table,
|
||||
{
|
||||
config: { apiUrl: null },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow']
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
@ -71,13 +182,13 @@ describe('ServiceNow service', () => {
|
|||
test('throws without username', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
table,
|
||||
{
|
||||
config: { apiUrl: 'test.com' },
|
||||
secrets: { username: '', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow']
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
@ -85,13 +196,13 @@ describe('ServiceNow service', () => {
|
|||
test('throws without password', () => {
|
||||
expect(() =>
|
||||
createExternalService(
|
||||
table,
|
||||
{
|
||||
config: { apiUrl: 'test.com' },
|
||||
secrets: { username: '', password: undefined },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow']
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
@ -116,19 +227,20 @@ describe('ServiceNow service', () => {
|
|||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
|
||||
url: 'https://example.com/api/now/v2/table/incident/1',
|
||||
method: 'get',
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
'sn_si_incident',
|
||||
{
|
||||
config: { apiUrl: 'https://dev102283.service-now.com/' },
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
|
||||
);
|
||||
|
||||
requestMock.mockImplementation(() => ({
|
||||
|
@ -140,7 +252,8 @@ describe('ServiceNow service', () => {
|
|||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
|
||||
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
|
||||
method: 'get',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -166,214 +279,346 @@ describe('ServiceNow service', () => {
|
|||
});
|
||||
|
||||
describe('createIncident', () => {
|
||||
test('it creates the incident correctly', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
|
||||
const res = await service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
// new connectors
|
||||
describe('import set table', () => {
|
||||
test('it creates the incident correctly', async () => {
|
||||
const res = await createIncident(service);
|
||||
expect(res).toEqual({
|
||||
title: 'INC01',
|
||||
id: '1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
|
||||
});
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
title: 'INC01',
|
||||
id: '1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1',
|
||||
test('it should call request with correct arguments', async () => {
|
||||
await createIncident(service);
|
||||
expect(requestMock).toHaveBeenCalledTimes(3);
|
||||
expectImportedIncident(false);
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow-sir']
|
||||
);
|
||||
|
||||
const res = await createIncident(service);
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(2, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident',
|
||||
method: 'post',
|
||||
data: { u_short_description: 'title', u_description: 'desc' },
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(3, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
|
||||
});
|
||||
|
||||
test('it should throw an error when the application is not installed', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error when instance is not alive', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
status: 200,
|
||||
data: {},
|
||||
request: { connection: { servername: 'Developer instance' } },
|
||||
}));
|
||||
await expect(
|
||||
service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'There is an issue with your Service Now Instance. Please check Developer instance.'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error when there is an import set api error', async () => {
|
||||
requestMock.mockImplementation(() => ({ data: getImportSetAPIError() }));
|
||||
await expect(
|
||||
service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
|
||||
await service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
// old connectors
|
||||
describe('table API', () => {
|
||||
beforeEach(() => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
|
||||
);
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/incident',
|
||||
method: 'post',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
});
|
||||
test('it creates the incident correctly', async () => {
|
||||
mockIncidentResponse(false);
|
||||
const res = await service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
'sn_si_incident',
|
||||
{
|
||||
config: { apiUrl: 'https://dev102283.service-now.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
);
|
||||
expect(res).toEqual({
|
||||
title: 'INC01',
|
||||
id: '1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
|
||||
});
|
||||
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
|
||||
const res = await service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
expect(requestMock).toHaveBeenCalledTimes(2);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/incident',
|
||||
method: 'post',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident',
|
||||
method: 'post',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }
|
||||
);
|
||||
|
||||
mockIncidentResponse(false);
|
||||
|
||||
const res = await service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/sn_si_incident',
|
||||
method: 'post',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
|
||||
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
|
||||
});
|
||||
|
||||
expect(res.url).toEqual(
|
||||
'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createIncident({
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to create incident. Error: An error has occurred'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error when instance is not alive', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
status: 200,
|
||||
data: {},
|
||||
request: { connection: { servername: 'Developer instance' } },
|
||||
}));
|
||||
await expect(service.getIncident('1')).rejects.toThrow(
|
||||
'There is an issue with your Service Now Instance. Please check Developer instance.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIncident', () => {
|
||||
test('it updates the incident correctly', async () => {
|
||||
patchMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
// new connectors
|
||||
describe('import set table', () => {
|
||||
test('it updates the incident correctly', async () => {
|
||||
const res = await updateIncident(service);
|
||||
|
||||
const res = await service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
expect(res).toEqual({
|
||||
title: 'INC01',
|
||||
id: '1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
|
||||
});
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
title: 'INC01',
|
||||
id: '1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1',
|
||||
test('it should call request with correct arguments', async () => {
|
||||
await updateIncident(service);
|
||||
expectImportedIncident(true);
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow-sir']
|
||||
);
|
||||
|
||||
const res = await updateIncident(service);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(2, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident',
|
||||
method: 'post',
|
||||
data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' },
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(3, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
|
||||
});
|
||||
|
||||
test('it should throw an error when the application is not installed', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error when instance is not alive', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
status: 200,
|
||||
data: {},
|
||||
request: { connection: { servername: 'Developer instance' } },
|
||||
}));
|
||||
await expect(
|
||||
service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'There is an issue with your Service Now Instance. Please check Developer instance.'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error when there is an import set api error', async () => {
|
||||
requestMock.mockImplementation(() => ({ data: getImportSetAPIError() }));
|
||||
await expect(
|
||||
service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments', async () => {
|
||||
patchMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
|
||||
await service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
// old connectors
|
||||
describe('table API', () => {
|
||||
beforeEach(() => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
|
||||
);
|
||||
});
|
||||
|
||||
expect(patchMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
'sn_si_incident',
|
||||
{
|
||||
config: { apiUrl: 'https://dev102283.service-now.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
);
|
||||
|
||||
patchMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
|
||||
const res = await service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
|
||||
expect(patchMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
|
||||
expect(res.url).toEqual(
|
||||
'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should throw an error', async () => {
|
||||
patchMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateIncident({
|
||||
test('it updates the incident correctly', async () => {
|
||||
mockIncidentResponse(true);
|
||||
const res = await service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred'
|
||||
);
|
||||
});
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
});
|
||||
|
||||
test('it creates the comment correctly', async () => {
|
||||
patchMock.mockImplementation(() => ({
|
||||
data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } },
|
||||
}));
|
||||
expect(res).toEqual({
|
||||
title: 'INC01',
|
||||
id: '1',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
|
||||
});
|
||||
|
||||
const res = await service.updateIncident({
|
||||
incidentId: '1',
|
||||
comment: 'comment-1',
|
||||
expect(requestMock).toHaveBeenCalledTimes(2);
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/incident/1',
|
||||
method: 'patch',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
title: 'INC011',
|
||||
id: '11',
|
||||
pushedDate: '2020-03-10T12:24:20.000Z',
|
||||
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11',
|
||||
});
|
||||
});
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }
|
||||
);
|
||||
|
||||
test('it should throw an error when instance is not alive', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
status: 200,
|
||||
data: {},
|
||||
request: { connection: { servername: 'Developer instance' } },
|
||||
}));
|
||||
await expect(service.getIncident('1')).rejects.toThrow(
|
||||
'There is an issue with your Service Now Instance. Please check Developer instance.'
|
||||
);
|
||||
mockIncidentResponse(false);
|
||||
|
||||
const res = await service.updateIncident({
|
||||
incidentId: '1',
|
||||
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
|
||||
method: 'patch',
|
||||
data: { short_description: 'title', description: 'desc' },
|
||||
});
|
||||
|
||||
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -388,7 +633,7 @@ describe('ServiceNow service', () => {
|
|||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
|
||||
url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -402,13 +647,13 @@ describe('ServiceNow service', () => {
|
|||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
'sn_si_incident',
|
||||
{
|
||||
config: { apiUrl: 'https://dev102283.service-now.com/' },
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
|
||||
);
|
||||
|
||||
requestMock.mockImplementation(() => ({
|
||||
|
@ -420,7 +665,7 @@ describe('ServiceNow service', () => {
|
|||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
|
||||
url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -456,7 +701,7 @@ describe('ServiceNow service', () => {
|
|||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
|
||||
url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -470,13 +715,13 @@ describe('ServiceNow service', () => {
|
|||
|
||||
test('it should call request with correct arguments when table changes', async () => {
|
||||
service = createExternalService(
|
||||
'sn_si_incident',
|
||||
{
|
||||
config: { apiUrl: 'https://dev102283.service-now.com/' },
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
|
||||
);
|
||||
|
||||
requestMock.mockImplementation(() => ({
|
||||
|
@ -489,7 +734,7 @@ describe('ServiceNow service', () => {
|
|||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
|
||||
url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -513,4 +758,79 @@ describe('ServiceNow service', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrl', () => {
|
||||
test('it returns the instance url', async () => {
|
||||
expect(service.getUrl()).toBe('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkInstance', () => {
|
||||
test('it throws an error if there is no result on data', () => {
|
||||
const res = { status: 200, data: {} } as AxiosResponse;
|
||||
expect(() => service.checkInstance(res)).toThrow();
|
||||
});
|
||||
|
||||
test('it does NOT throws an error if the status > 400', () => {
|
||||
const res = { status: 500, data: {} } as AxiosResponse;
|
||||
expect(() => service.checkInstance(res)).not.toThrow();
|
||||
});
|
||||
|
||||
test('it shows the servername', () => {
|
||||
const res = {
|
||||
status: 200,
|
||||
data: {},
|
||||
request: { connection: { servername: 'https://example.com' } },
|
||||
} as AxiosResponse;
|
||||
expect(() => service.checkInstance(res)).toThrow(
|
||||
'There is an issue with your Service Now Instance. Please check https://example.com.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('getApplicationInformation', () => {
|
||||
test('it returns the application information', async () => {
|
||||
mockApplicationVersion();
|
||||
const res = await service.getApplicationInformation();
|
||||
expect(res).toEqual({
|
||||
name: 'Elastic',
|
||||
scope: 'x_elas2_inc_int',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
test('it should throw an error', async () => {
|
||||
requestMock.mockImplementation(() => {
|
||||
throw new Error('An error has occurred');
|
||||
});
|
||||
await expect(service.getApplicationInformation()).rejects.toThrow(
|
||||
'[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkIfApplicationIsInstalled', () => {
|
||||
test('it logs the application information', async () => {
|
||||
mockApplicationVersion();
|
||||
await service.checkIfApplicationIsInstalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0'
|
||||
);
|
||||
});
|
||||
|
||||
test('it does not log if useOldApi = true', async () => {
|
||||
service = createExternalService(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
{ ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
|
||||
);
|
||||
await service.checkIfApplicationIsInstalled();
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,28 +7,35 @@
|
|||
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
|
||||
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types';
|
||||
import {
|
||||
ExternalServiceCredentials,
|
||||
ExternalService,
|
||||
ExternalServiceParamsCreate,
|
||||
ExternalServiceParamsUpdate,
|
||||
ImportSetApiResponse,
|
||||
ImportSetApiResponseError,
|
||||
ServiceNowIncident,
|
||||
GetApplicationInfoResponse,
|
||||
SNProductsConfigValue,
|
||||
ServiceFactory,
|
||||
} from './types';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import {
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
ResponseError,
|
||||
} from './types';
|
||||
import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils';
|
||||
import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types';
|
||||
import { request } from '../lib/axios_utils';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { createServiceError, getPushedDate, prepareIncident } from './utils';
|
||||
|
||||
const API_VERSION = 'v2';
|
||||
const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`;
|
||||
export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`;
|
||||
|
||||
export const createExternalService = (
|
||||
table: string,
|
||||
export const createExternalService: ServiceFactory = (
|
||||
{ config, secrets }: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
{ table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue
|
||||
): ExternalService => {
|
||||
const { apiUrl: url } = config as ServiceNowPublicConfigurationType;
|
||||
const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType;
|
||||
const { username, password } = secrets as ServiceNowSecretConfigurationType;
|
||||
|
||||
if (!url || !username || !password) {
|
||||
|
@ -36,13 +43,26 @@ export const createExternalService = (
|
|||
}
|
||||
|
||||
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`;
|
||||
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
|
||||
const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`;
|
||||
const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`;
|
||||
const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`;
|
||||
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
|
||||
const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`;
|
||||
/**
|
||||
* Need to be set the same at:
|
||||
* x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts
|
||||
*/
|
||||
const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`;
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
auth: { username, password },
|
||||
});
|
||||
|
||||
const useOldApi = !useImportAPI || isLegacy;
|
||||
|
||||
const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl);
|
||||
const getUpdateIncidentUrl = (incidentId: string) =>
|
||||
useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl;
|
||||
|
||||
const getIncidentViewURL = (id: string) => {
|
||||
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
|
||||
return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`;
|
||||
|
@ -57,7 +77,7 @@ export const createExternalService = (
|
|||
};
|
||||
|
||||
const checkInstance = (res: AxiosResponse) => {
|
||||
if (res.status === 200 && res.data.result == null) {
|
||||
if (res.status >= 200 && res.status < 400 && res.data.result == null) {
|
||||
throw new Error(
|
||||
`There is an issue with your Service Now Instance. Please check ${
|
||||
res.request?.connection?.servername ?? ''
|
||||
|
@ -66,34 +86,70 @@ export const createExternalService = (
|
|||
}
|
||||
};
|
||||
|
||||
const createErrorMessage = (errorResponse: ResponseError): string => {
|
||||
if (errorResponse == null) {
|
||||
return '';
|
||||
const isImportSetApiResponseAnError = (
|
||||
data: ImportSetApiResponse['result'][0]
|
||||
): data is ImportSetApiResponseError['result'][0] => data.status === 'error';
|
||||
|
||||
const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => {
|
||||
if (res.result.length === 0) {
|
||||
throw new Error('Unexpected result');
|
||||
}
|
||||
|
||||
const { error } = errorResponse;
|
||||
return error != null ? `${error?.message}: ${error?.detail}` : '';
|
||||
const data = res.result[0];
|
||||
|
||||
// Create ResponseError message?
|
||||
if (isImportSetApiResponseAnError(data)) {
|
||||
throw new Error(data.error_message);
|
||||
}
|
||||
};
|
||||
|
||||
const getIncident = async (id: string) => {
|
||||
/**
|
||||
* Gets the Elastic SN Application information including the current version.
|
||||
* It should not be used on legacy connectors.
|
||||
*/
|
||||
const getApplicationInformation = async (): Promise<GetApplicationInfoResponse> => {
|
||||
try {
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url: `${incidentUrl}/${id}`,
|
||||
url: getVersionUrl(),
|
||||
logger,
|
||||
configurationUtilities,
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
checkInstance(res);
|
||||
|
||||
return { ...res.data.result };
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`Unable to get incident with id ${id}. Error: ${
|
||||
error.message
|
||||
} Reason: ${createErrorMessage(error.response?.data)}`
|
||||
)
|
||||
);
|
||||
throw createServiceError(error, 'Unable to get application version');
|
||||
}
|
||||
};
|
||||
|
||||
const logApplicationInfo = (scope: string, version: string) =>
|
||||
logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`);
|
||||
|
||||
const checkIfApplicationIsInstalled = async () => {
|
||||
if (!useOldApi) {
|
||||
const { version, scope } = await getApplicationInformation();
|
||||
logApplicationInfo(scope, version);
|
||||
}
|
||||
};
|
||||
|
||||
const getIncident = async (id: string): Promise<ServiceNowIncident> => {
|
||||
try {
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url: `${tableApiIncidentUrl}/${id}`,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
checkInstance(res);
|
||||
|
||||
return { ...res.data.result };
|
||||
} catch (error) {
|
||||
throw createServiceError(error, `Unable to get incident with id ${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -101,7 +157,7 @@ export const createExternalService = (
|
|||
try {
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url: incidentUrl,
|
||||
url: tableApiIncidentUrl,
|
||||
logger,
|
||||
params,
|
||||
configurationUtilities,
|
||||
|
@ -109,71 +165,80 @@ export const createExternalService = (
|
|||
checkInstance(res);
|
||||
return res.data.result.length > 0 ? { ...res.data.result } : undefined;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage(
|
||||
error.response?.data
|
||||
)}`
|
||||
)
|
||||
);
|
||||
throw createServiceError(error, 'Unable to find incidents by query');
|
||||
}
|
||||
};
|
||||
|
||||
const createIncident = async ({ incident }: ExternalServiceParams) => {
|
||||
const getUrl = () => urlWithoutTrailingSlash;
|
||||
|
||||
const createIncident = async ({ incident }: ExternalServiceParamsCreate) => {
|
||||
try {
|
||||
await checkIfApplicationIsInstalled();
|
||||
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url: `${incidentUrl}`,
|
||||
url: getCreateIncidentUrl(),
|
||||
logger,
|
||||
method: 'post',
|
||||
data: { ...(incident as Record<string, unknown>) },
|
||||
data: prepareIncident(useOldApi, incident),
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
checkInstance(res);
|
||||
|
||||
if (!useOldApi) {
|
||||
throwIfImportSetApiResponseIsAnError(res.data);
|
||||
}
|
||||
|
||||
const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id;
|
||||
const insertedIncident = await getIncident(incidentId);
|
||||
|
||||
return {
|
||||
title: res.data.result.number,
|
||||
id: res.data.result.sys_id,
|
||||
pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(),
|
||||
url: getIncidentViewURL(res.data.result.sys_id),
|
||||
title: insertedIncident.number,
|
||||
id: insertedIncident.sys_id,
|
||||
pushedDate: getPushedDate(insertedIncident.sys_created_on),
|
||||
url: getIncidentViewURL(insertedIncident.sys_id),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage(
|
||||
error.response?.data
|
||||
)}`
|
||||
)
|
||||
);
|
||||
throw createServiceError(error, 'Unable to create incident');
|
||||
}
|
||||
};
|
||||
|
||||
const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => {
|
||||
const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => {
|
||||
try {
|
||||
const res = await patch({
|
||||
await checkIfApplicationIsInstalled();
|
||||
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url: `${incidentUrl}/${incidentId}`,
|
||||
url: getUpdateIncidentUrl(incidentId),
|
||||
// Import Set API supports only POST.
|
||||
method: useOldApi ? 'patch' : 'post',
|
||||
logger,
|
||||
data: { ...(incident as Record<string, unknown>) },
|
||||
data: {
|
||||
...prepareIncident(useOldApi, incident),
|
||||
// elastic_incident_id is used to update the incident when using the Import Set API.
|
||||
...(useOldApi ? {} : { elastic_incident_id: incidentId }),
|
||||
},
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
checkInstance(res);
|
||||
|
||||
if (!useOldApi) {
|
||||
throwIfImportSetApiResponseIsAnError(res.data);
|
||||
}
|
||||
|
||||
const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id;
|
||||
const updatedIncident = await getIncident(id);
|
||||
|
||||
return {
|
||||
title: res.data.result.number,
|
||||
id: res.data.result.sys_id,
|
||||
pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(),
|
||||
url: getIncidentViewURL(res.data.result.sys_id),
|
||||
title: updatedIncident.number,
|
||||
id: updatedIncident.sys_id,
|
||||
pushedDate: getPushedDate(updatedIncident.sys_updated_on),
|
||||
url: getIncidentViewURL(updatedIncident.sys_id),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`Unable to update incident with id ${incidentId}. Error: ${
|
||||
error.message
|
||||
} Reason: ${createErrorMessage(error.response?.data)}`
|
||||
)
|
||||
);
|
||||
throw createServiceError(error, `Unable to update incident with id ${incidentId}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -185,17 +250,12 @@ export const createExternalService = (
|
|||
logger,
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
checkInstance(res);
|
||||
|
||||
return res.data.result.length > 0 ? res.data.result : [];
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage(
|
||||
error.response?.data
|
||||
)}`
|
||||
)
|
||||
);
|
||||
throw createServiceError(error, 'Unable to get fields');
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -210,14 +270,7 @@ export const createExternalService = (
|
|||
checkInstance(res);
|
||||
return res.data.result;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage(
|
||||
error.response?.data
|
||||
)}`
|
||||
)
|
||||
);
|
||||
throw createServiceError(error, 'Unable to get choices');
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -228,5 +281,9 @@ export const createExternalService = (
|
|||
getIncident,
|
||||
updateIncident,
|
||||
getChoices,
|
||||
getUrl,
|
||||
checkInstance,
|
||||
getApplicationInformation,
|
||||
checkIfApplicationIsInstalled,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import { createExternalServiceSIR } from './service_sir';
|
||||
import * as utils from '../lib/axios_utils';
|
||||
import { ExternalServiceSIR } from './types';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
import { observables } from './mocks';
|
||||
import { snExternalServiceConfig } from './config';
|
||||
|
||||
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('../lib/axios_utils', () => {
|
||||
const originalUtils = jest.requireActual('../lib/axios_utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
request: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
axios.create = jest.fn(() => axios);
|
||||
const requestMock = utils.request as jest.Mock;
|
||||
const configurationUtilities = actionsConfigMock.create();
|
||||
|
||||
const mockApplicationVersion = () =>
|
||||
requestMock.mockImplementationOnce(() => ({
|
||||
data: {
|
||||
result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' },
|
||||
},
|
||||
}));
|
||||
|
||||
const getAddObservablesResponse = () => [
|
||||
{
|
||||
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
|
||||
observable_sys_id: '1',
|
||||
},
|
||||
{
|
||||
value: '127.0.0.1',
|
||||
observable_sys_id: '2',
|
||||
},
|
||||
{
|
||||
value: 'https://example.com',
|
||||
observable_sys_id: '3',
|
||||
},
|
||||
];
|
||||
|
||||
const mockAddObservablesResponse = (single: boolean) => {
|
||||
const res = getAddObservablesResponse();
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: {
|
||||
result: single ? res[0] : res,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const expectAddObservables = (single: boolean) => {
|
||||
expect(requestMock).toHaveBeenNthCalledWith(1, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const url = single
|
||||
? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables'
|
||||
: 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk';
|
||||
|
||||
const data = single ? observables[0] : observables;
|
||||
|
||||
expect(requestMock).toHaveBeenNthCalledWith(2, {
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ServiceNow SIR service', () => {
|
||||
let service: ExternalServiceSIR;
|
||||
|
||||
beforeEach(() => {
|
||||
service = createExternalServiceSIR(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow-sir']
|
||||
) as ExternalServiceSIR;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bulkAddObservableToIncident', () => {
|
||||
test('it adds multiple observables correctly', async () => {
|
||||
mockApplicationVersion();
|
||||
mockAddObservablesResponse(false);
|
||||
|
||||
const res = await service.bulkAddObservableToIncident(observables, 'incident-1');
|
||||
expect(res).toEqual(getAddObservablesResponse());
|
||||
expectAddObservables(false);
|
||||
});
|
||||
|
||||
test('it adds a single observable correctly', async () => {
|
||||
mockApplicationVersion();
|
||||
mockAddObservablesResponse(true);
|
||||
|
||||
const res = await service.addObservableToIncident(observables[0], 'incident-1');
|
||||
expect(res).toEqual(getAddObservablesResponse()[0]);
|
||||
expectAddObservables(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
ExternalServiceCredentials,
|
||||
SNProductsConfigValue,
|
||||
Observable,
|
||||
ExternalServiceSIR,
|
||||
ObservableResponse,
|
||||
ServiceFactory,
|
||||
} from './types';
|
||||
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { ServiceNowSecretConfigurationType } from './types';
|
||||
import { request } from '../lib/axios_utils';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { createExternalService } from './service';
|
||||
import { createServiceError } from './utils';
|
||||
|
||||
const getAddObservableToIncidentURL = (url: string, incidentID: string) =>
|
||||
`${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`;
|
||||
|
||||
const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) =>
|
||||
`${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`;
|
||||
|
||||
export const createExternalServiceSIR: ServiceFactory = (
|
||||
credentials: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
serviceConfig: SNProductsConfigValue
|
||||
): ExternalServiceSIR => {
|
||||
const snService = createExternalService(
|
||||
credentials,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
serviceConfig
|
||||
);
|
||||
|
||||
const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType;
|
||||
const axiosInstance = axios.create({
|
||||
auth: { username, password },
|
||||
});
|
||||
|
||||
const _addObservable = async (data: Observable | Observable[], url: string) => {
|
||||
snService.checkIfApplicationIsInstalled();
|
||||
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url,
|
||||
logger,
|
||||
method: 'post',
|
||||
data,
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
snService.checkInstance(res);
|
||||
return res.data.result;
|
||||
};
|
||||
|
||||
const addObservableToIncident = async (
|
||||
observable: Observable,
|
||||
incidentID: string
|
||||
): Promise<ObservableResponse> => {
|
||||
try {
|
||||
return await _addObservable(
|
||||
observable,
|
||||
getAddObservableToIncidentURL(snService.getUrl(), incidentID)
|
||||
);
|
||||
} catch (error) {
|
||||
throw createServiceError(
|
||||
error,
|
||||
`Unable to add observable to security incident with id ${incidentID}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const bulkAddObservableToIncident = async (
|
||||
observables: Observable[],
|
||||
incidentID: string
|
||||
): Promise<ObservableResponse[]> => {
|
||||
try {
|
||||
return await _addObservable(
|
||||
observables,
|
||||
getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID)
|
||||
);
|
||||
} catch (error) {
|
||||
throw createServiceError(
|
||||
error,
|
||||
`Unable to add observables to security incident with id ${incidentID}`
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
...snService,
|
||||
addObservableToIncident,
|
||||
bulkAddObservableToIncident,
|
||||
};
|
||||
};
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import {
|
||||
ExecutorParamsSchemaITSM,
|
||||
|
@ -78,15 +79,29 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
|
|||
comments?: ExternalServiceCommentResponse[];
|
||||
}
|
||||
|
||||
export type ExternalServiceParams = Record<string, unknown>;
|
||||
export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
|
||||
export type PartialIncident = Partial<Incident>;
|
||||
|
||||
export interface ExternalServiceParamsCreate {
|
||||
incident: Incident & Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExternalServiceParamsUpdate {
|
||||
incidentId: string;
|
||||
incident: PartialIncident & Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExternalService {
|
||||
getChoices: (fields: string[]) => Promise<GetChoicesResponse>;
|
||||
getIncident: (id: string) => Promise<ExternalServiceParams | undefined>;
|
||||
getIncident: (id: string) => Promise<ServiceNowIncident>;
|
||||
getFields: () => Promise<GetCommonFieldsResponse>;
|
||||
createIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
|
||||
updateIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
|
||||
findIncidents: (params?: Record<string, string>) => Promise<ExternalServiceParams[] | undefined>;
|
||||
createIncident: (params: ExternalServiceParamsCreate) => Promise<ExternalServiceIncidentResponse>;
|
||||
updateIncident: (params: ExternalServiceParamsUpdate) => Promise<ExternalServiceIncidentResponse>;
|
||||
findIncidents: (params?: Record<string, string>) => Promise<ServiceNowIncident>;
|
||||
getUrl: () => string;
|
||||
checkInstance: (res: AxiosResponse) => void;
|
||||
getApplicationInformation: () => Promise<GetApplicationInfoResponse>;
|
||||
checkIfApplicationIsInstalled: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type PushToServiceApiParams = ExecutorSubActionPushParams;
|
||||
|
@ -115,10 +130,9 @@ export type ServiceNowSIRIncident = Omit<
|
|||
'externalId'
|
||||
>;
|
||||
|
||||
export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
|
||||
|
||||
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
|
||||
params: PushToServiceApiParams;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, unknown>;
|
||||
logger: Logger;
|
||||
commentFieldKey: string;
|
||||
|
@ -158,12 +172,20 @@ export interface GetChoicesHandlerArgs {
|
|||
params: ExecutorSubActionGetChoicesParams;
|
||||
}
|
||||
|
||||
export interface ExternalServiceApi {
|
||||
export interface ServiceNowIncident {
|
||||
sys_id: string;
|
||||
number: string;
|
||||
sys_created_on: string;
|
||||
sys_updated_on: string;
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExternalServiceAPI {
|
||||
getChoices: (args: GetChoicesHandlerArgs) => Promise<GetChoicesResponse>;
|
||||
getFields: (args: GetCommonFieldsHandlerArgs) => Promise<GetCommonFieldsResponse>;
|
||||
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
|
||||
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
|
||||
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
|
||||
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<ServiceNowIncident>;
|
||||
}
|
||||
|
||||
export interface ExternalServiceCommentResponse {
|
||||
|
@ -173,10 +195,90 @@ export interface ExternalServiceCommentResponse {
|
|||
}
|
||||
|
||||
type TypeNullOrUndefined<T> = T | null | undefined;
|
||||
export interface ResponseError {
|
||||
|
||||
export interface ServiceNowError {
|
||||
error: TypeNullOrUndefined<{
|
||||
message: TypeNullOrUndefined<string>;
|
||||
detail: TypeNullOrUndefined<string>;
|
||||
}>;
|
||||
status: TypeNullOrUndefined<string>;
|
||||
}
|
||||
|
||||
export type ResponseError = AxiosError<ServiceNowError>;
|
||||
|
||||
export interface ImportSetApiResponseSuccess {
|
||||
import_set: string;
|
||||
staging_table: string;
|
||||
result: Array<{
|
||||
display_name: string;
|
||||
display_value: string;
|
||||
record_link: string;
|
||||
status: string;
|
||||
sys_id: string;
|
||||
table: string;
|
||||
transform_map: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ImportSetApiResponseError {
|
||||
import_set: string;
|
||||
staging_table: string;
|
||||
result: Array<{
|
||||
error_message: string;
|
||||
status_message: string;
|
||||
status: string;
|
||||
transform_map: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError;
|
||||
export interface GetApplicationInfoResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface SNProductsConfigValue {
|
||||
table: string;
|
||||
appScope: string;
|
||||
useImportAPI: boolean;
|
||||
importSetTable: string;
|
||||
commentFieldKey: string;
|
||||
}
|
||||
|
||||
export type SNProductsConfig = Record<string, SNProductsConfigValue>;
|
||||
|
||||
export enum ObservableTypes {
|
||||
ip4 = 'ipv4-addr',
|
||||
url = 'URL',
|
||||
sha256 = 'SHA256',
|
||||
}
|
||||
|
||||
export interface Observable {
|
||||
value: string;
|
||||
type: ObservableTypes;
|
||||
}
|
||||
|
||||
export interface ObservableResponse {
|
||||
value: string;
|
||||
observable_sys_id: ObservableTypes;
|
||||
}
|
||||
|
||||
export interface ExternalServiceSIR extends ExternalService {
|
||||
addObservableToIncident: (
|
||||
observable: Observable,
|
||||
incidentID: string
|
||||
) => Promise<ObservableResponse>;
|
||||
bulkAddObservableToIncident: (
|
||||
observables: Observable[],
|
||||
incidentID: string
|
||||
) => Promise<ObservableResponse[]>;
|
||||
}
|
||||
|
||||
export type ServiceFactory = (
|
||||
credentials: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
serviceConfig: SNProductsConfigValue
|
||||
) => ExternalServiceSIR | ExternalService;
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import { prepareIncident, createServiceError, getPushedDate } from './utils';
|
||||
|
||||
/**
|
||||
* The purpose of this test is to
|
||||
* prevent developers from accidentally
|
||||
* change important configuration values
|
||||
* such as the scope or the import set table
|
||||
* of our ServiceNow application
|
||||
*/
|
||||
|
||||
describe('utils', () => {
|
||||
describe('prepareIncident', () => {
|
||||
test('it prepares the incident correctly when useOldApi=false', async () => {
|
||||
const incident = { short_description: 'title', description: 'desc' };
|
||||
const newIncident = prepareIncident(false, incident);
|
||||
expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' });
|
||||
});
|
||||
|
||||
test('it prepares the incident correctly when useOldApi=true', async () => {
|
||||
const incident = { short_description: 'title', description: 'desc' };
|
||||
const newIncident = prepareIncident(true, incident);
|
||||
expect(newIncident).toEqual(incident);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createServiceError', () => {
|
||||
test('it creates an error when the response is null', async () => {
|
||||
const error = new Error('An error occurred');
|
||||
// @ts-expect-error
|
||||
expect(createServiceError(error, 'Unable to do action').message).toBe(
|
||||
'[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null'
|
||||
);
|
||||
});
|
||||
|
||||
test('it creates an error with response correctly', async () => {
|
||||
const axiosError = {
|
||||
message: 'An error occurred',
|
||||
response: { data: { error: { message: 'Denied', detail: 'no access' } } },
|
||||
} as AxiosError;
|
||||
|
||||
expect(createServiceError(axiosError, 'Unable to do action').message).toBe(
|
||||
'[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access'
|
||||
);
|
||||
});
|
||||
|
||||
test('it creates an error correctly when the ServiceNow error is null', async () => {
|
||||
const axiosError = {
|
||||
message: 'An error occurred',
|
||||
response: { data: { error: null } },
|
||||
} as AxiosError;
|
||||
|
||||
expect(createServiceError(axiosError, 'Unable to do action').message).toBe(
|
||||
'[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPushedDate', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers('modern');
|
||||
jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('it formats the date correctly if timestamp is provided', async () => {
|
||||
expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z');
|
||||
});
|
||||
|
||||
test('it formats the date correctly if timestamp is not provided', async () => {
|
||||
expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types';
|
||||
import { FIELD_PREFIX } from './config';
|
||||
import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident =>
|
||||
useOldApi
|
||||
? incident
|
||||
: Object.entries(incident).reduce(
|
||||
(acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }),
|
||||
{} as Incident
|
||||
);
|
||||
|
||||
const createErrorMessage = (errorResponse?: ServiceNowError): string => {
|
||||
if (errorResponse == null) {
|
||||
return 'unknown: errorResponse was null';
|
||||
}
|
||||
|
||||
const { error } = errorResponse;
|
||||
return error != null
|
||||
? `${error?.message}: ${error?.detail}`
|
||||
: 'unknown: no error in error response';
|
||||
};
|
||||
|
||||
export const createServiceError = (error: ResponseError, message: string) =>
|
||||
new Error(
|
||||
getErrorMessage(
|
||||
i18n.SERVICENOW,
|
||||
`${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}`
|
||||
)
|
||||
);
|
||||
|
||||
export const getPushedDate = (timestamp?: string) => {
|
||||
if (timestamp != null) {
|
||||
return new Date(addTimeZoneToDate(timestamp)).toISOString();
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
};
|
12
x-pack/plugins/actions/server/constants/connectors.ts
Normal file
12
x-pack/plugins/actions/server/constants/connectors.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
// TODO: Remove when Elastic for ITSM is published.
|
||||
export const ENABLE_NEW_SN_ITSM_CONNECTOR = true;
|
||||
|
||||
// TODO: Remove when Elastic for Security Operations is published.
|
||||
export const ENABLE_NEW_SN_SIR_CONNECTOR = true;
|
|
@ -165,6 +165,47 @@ describe('successful migrations', () => {
|
|||
});
|
||||
expect(migratedAction).toEqual(action);
|
||||
});
|
||||
|
||||
test('set isLegacy config property for .servicenow', () => {
|
||||
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
|
||||
const action = getMockDataForServiceNow();
|
||||
const migratedAction = migration716(action, context);
|
||||
|
||||
expect(migratedAction).toEqual({
|
||||
...action,
|
||||
attributes: {
|
||||
...action.attributes,
|
||||
config: {
|
||||
apiUrl: 'https://example.com',
|
||||
isLegacy: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('set isLegacy config property for .servicenow-sir', () => {
|
||||
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
|
||||
const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' });
|
||||
const migratedAction = migration716(action, context);
|
||||
|
||||
expect(migratedAction).toEqual({
|
||||
...action,
|
||||
attributes: {
|
||||
...action.attributes,
|
||||
config: {
|
||||
apiUrl: 'https://example.com',
|
||||
isLegacy: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it does not set isLegacy config for other connectors', () => {
|
||||
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
|
||||
const action = getMockData();
|
||||
const migratedAction = migration716(action, context);
|
||||
expect(migratedAction).toEqual(action);
|
||||
});
|
||||
});
|
||||
|
||||
describe('8.0.0', () => {
|
||||
|
@ -306,3 +347,19 @@ function getMockData(
|
|||
type: 'action',
|
||||
};
|
||||
}
|
||||
|
||||
function getMockDataForServiceNow(
|
||||
overwrites: Record<string, unknown> = {}
|
||||
): SavedObjectUnsanitizedDoc<Omit<RawAction, 'isMissingSecrets'>> {
|
||||
return {
|
||||
attributes: {
|
||||
name: 'abc',
|
||||
actionTypeId: '.servicenow',
|
||||
config: { apiUrl: 'https://example.com' },
|
||||
secrets: { user: 'test', password: '123' },
|
||||
...overwrites,
|
||||
},
|
||||
id: uuid.v4(),
|
||||
type: 'action',
|
||||
};
|
||||
}
|
||||
|
|
|
@ -59,13 +59,16 @@ export function getActionsMigrations(
|
|||
const migrationActionsFourteen = createEsoMigration(
|
||||
encryptedSavedObjects,
|
||||
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> => true,
|
||||
pipeMigrations(addisMissingSecretsField)
|
||||
pipeMigrations(addIsMissingSecretsField)
|
||||
);
|
||||
|
||||
const migrationEmailActionsSixteen = createEsoMigration(
|
||||
const migrationActionsSixteen = createEsoMigration(
|
||||
encryptedSavedObjects,
|
||||
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> => doc.attributes.actionTypeId === '.email',
|
||||
pipeMigrations(setServiceConfigIfNotSet)
|
||||
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> =>
|
||||
doc.attributes.actionTypeId === '.servicenow' ||
|
||||
doc.attributes.actionTypeId === '.servicenow-sir' ||
|
||||
doc.attributes.actionTypeId === '.email',
|
||||
pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet)
|
||||
);
|
||||
|
||||
const migrationActions800 = createEsoMigration(
|
||||
|
@ -79,7 +82,7 @@ export function getActionsMigrations(
|
|||
'7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'),
|
||||
'7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'),
|
||||
'7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'),
|
||||
'7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'),
|
||||
'7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'),
|
||||
'8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'),
|
||||
};
|
||||
}
|
||||
|
@ -182,7 +185,7 @@ const setServiceConfigIfNotSet = (
|
|||
};
|
||||
};
|
||||
|
||||
const addisMissingSecretsField = (
|
||||
const addIsMissingSecretsField = (
|
||||
doc: SavedObjectUnsanitizedDoc<RawAction>
|
||||
): SavedObjectUnsanitizedDoc<RawAction> => {
|
||||
return {
|
||||
|
@ -194,6 +197,28 @@ const addisMissingSecretsField = (
|
|||
};
|
||||
};
|
||||
|
||||
const markOldServiceNowITSMConnectorAsLegacy = (
|
||||
doc: SavedObjectUnsanitizedDoc<RawAction>
|
||||
): SavedObjectUnsanitizedDoc<RawAction> => {
|
||||
if (
|
||||
doc.attributes.actionTypeId !== '.servicenow' &&
|
||||
doc.attributes.actionTypeId !== '.servicenow-sir'
|
||||
) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
return {
|
||||
...doc,
|
||||
attributes: {
|
||||
...doc.attributes,
|
||||
config: {
|
||||
...doc.attributes.config,
|
||||
isLegacy: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function pipeMigrations(...migrations: ActionMigration[]): ActionMigration {
|
||||
return (doc: SavedObjectUnsanitizedDoc<RawAction>) =>
|
||||
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
Case management in Kibana
|
||||
# Case management in Kibana
|
||||
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![Pull Requests][pr-shield]][pr-url]
|
||||
[![Pull Requests][pr-shield]][pr-url]
|
||||
|
||||
# Cases Plugin Docs
|
||||
# Docs
|
||||
|
||||
![Cases Logo][cases-logo]
|
||||
|
||||
|
@ -288,9 +288,9 @@ Connectors of type (`.none`) should have the `fields` attribute set to `null`.
|
|||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
[pr-shield]: https://img.shields.io/github/issues-pr/elangosundar/awesome-README-templates?style=for-the-badge
|
||||
[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+label%3AFeature%3ACases+-is%3Adraft+is%3Aopen+
|
||||
[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge
|
||||
[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge
|
||||
[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22
|
||||
[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge
|
||||
[issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases
|
||||
[cases-logo]: images/logo.png
|
||||
[configure-img]: images/configure.png
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
User,
|
||||
UserAction,
|
||||
UserActionField,
|
||||
ActionConnector,
|
||||
} from '../api';
|
||||
|
||||
export interface CasesUiConfigType {
|
||||
|
@ -259,3 +260,5 @@ export interface Ecs {
|
|||
_index?: string;
|
||||
signal?: SignalEcs;
|
||||
}
|
||||
|
||||
export type CaseActionConnector = ActionConnector;
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { CaseActionConnector } from '../../../common';
|
||||
|
||||
const getUniqueActionTypeIds = (connectors: CaseActionConnector[]) =>
|
||||
new Set(connectors.map((connector) => connector.actionTypeId));
|
||||
|
||||
export const registerConnectorsToMockActionRegistry = (
|
||||
actionTypeRegistry: TriggersAndActionsUIPublicPluginStart['actionTypeRegistry'],
|
||||
connectors: CaseActionConnector[]
|
||||
) => {
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const uniqueActionTypeIds = getUniqueActionTypeIds(connectors);
|
||||
uniqueActionTypeIds.forEach((actionTypeId) =>
|
||||
actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
};
|
|
@ -19,8 +19,8 @@ import { useKibana } from '../../common/lib/kibana';
|
|||
import { StatusAll } from '../../containers/types';
|
||||
import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common';
|
||||
import { connectorsMock } from '../../containers/mock';
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
jest.mock('../../containers/use_get_reporters');
|
||||
jest.mock('../../containers/use_get_tags');
|
||||
|
@ -59,14 +59,10 @@ jest.mock('../../common/lib/kibana', () => {
|
|||
});
|
||||
|
||||
describe('AllCasesGeneric ', () => {
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
connectorsMock.forEach((connector) =>
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -12,21 +12,17 @@ import '../../common/mock/match_media';
|
|||
import { ExternalServiceColumn } from './columns';
|
||||
import { useGetCasesMockState } from '../../containers/mock';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { connectors } from '../configure_cases/__mock__';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
|
||||
describe('ExternalServiceColumn ', () => {
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
connectors.forEach((connector) =>
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
|
||||
});
|
||||
|
||||
it('Not pushed render', () => {
|
||||
|
|
|
@ -32,8 +32,8 @@ import { useKibana } from '../../common/lib/kibana';
|
|||
import { AllCasesGeneric as AllCases } from './all_cases_generic';
|
||||
import { AllCasesProps } from '.';
|
||||
import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns';
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
jest.mock('../../containers/use_bulk_update_case');
|
||||
jest.mock('../../containers/use_delete_cases');
|
||||
|
@ -148,14 +148,10 @@ describe('AllCasesGeneric', () => {
|
|||
userCanCrud: true,
|
||||
};
|
||||
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
connectorsMock.forEach((connector) =>
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { Connectors, Props } from './connectors';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
|
@ -14,6 +15,7 @@ import { ConnectorsDropdown } from './connectors_dropdown';
|
|||
import { connectors, actionTypes } from './__mock__';
|
||||
import { ConnectorTypes } from '../../../common';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
|
@ -35,11 +37,10 @@ describe('Connectors', () => {
|
|||
updateConnectorDisabled: false,
|
||||
};
|
||||
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({
|
||||
actionTypeTitle: 'test',
|
||||
iconClass: 'logoSecurity',
|
||||
});
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
|
||||
wrapper = mount(<Connectors {...props} />, { wrappingComponent: TestProviders });
|
||||
});
|
||||
|
||||
|
@ -121,4 +122,33 @@ describe('Connectors', () => {
|
|||
.text()
|
||||
).toBe('Update My Connector');
|
||||
});
|
||||
|
||||
test('it shows the deprecated callout when the connector is legacy', async () => {
|
||||
render(
|
||||
<Connectors
|
||||
{...props}
|
||||
selectedConnector={{ id: 'servicenow-legacy', type: ConnectorTypes.serviceNowITSM }}
|
||||
/>,
|
||||
{
|
||||
// wrapper: TestProviders produces a TS error
|
||||
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText('Deprecated connector type')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This connector type is deprecated. Create a new connector or update this connector'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it does not shows the deprecated callout when the connector is none', async () => {
|
||||
render(<Connectors {...props} />, {
|
||||
// wrapper: TestProviders produces a TS error
|
||||
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,6 +22,8 @@ import * as i18n from './translations';
|
|||
import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types';
|
||||
import { Mapping } from './mapping';
|
||||
import { ActionTypeConnector, ConnectorTypes } from '../../../common';
|
||||
import { DeprecatedCallout } from '../connectors/deprecated_callout';
|
||||
import { isLegacyConnector } from '../utils';
|
||||
|
||||
const EuiFormRowExtended = styled(EuiFormRow)`
|
||||
.euiFormRow__labelWrapper {
|
||||
|
@ -53,11 +55,13 @@ const ConnectorsComponent: React.FC<Props> = ({
|
|||
selectedConnector,
|
||||
updateConnectorDisabled,
|
||||
}) => {
|
||||
const connectorsName = useMemo(
|
||||
() => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none',
|
||||
const connector = useMemo(
|
||||
() => connectors.find((c) => c.id === selectedConnector.id),
|
||||
[connectors, selectedConnector.id]
|
||||
);
|
||||
|
||||
const connectorsName = connector?.name ?? 'none';
|
||||
|
||||
const actionTypeName = useMemo(
|
||||
() => actionTypes.find((c) => c.id === selectedConnector.type)?.name ?? 'Unknown',
|
||||
[actionTypes, selectedConnector.type]
|
||||
|
@ -107,6 +111,11 @@ const ConnectorsComponent: React.FC<Props> = ({
|
|||
appendAddConnectorButton={true}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{selectedConnector.type !== ConnectorTypes.none && isLegacyConnector(connector) && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<DeprecatedCallout />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{selectedConnector.type !== ConnectorTypes.none ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<Mapping
|
||||
|
|
|
@ -8,12 +8,13 @@
|
|||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { EuiSuperSelect } from '@elastic/eui';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { ConnectorsDropdown, Props } from './connectors_dropdown';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
import { connectors } from './__mock__';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
|
@ -28,14 +29,10 @@ describe('ConnectorsDropdown', () => {
|
|||
selectedConnector: 'none',
|
||||
};
|
||||
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
connectors.forEach((connector) =>
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
|
||||
wrapper = mount(<ConnectorsDropdown {...props} />, { wrappingComponent: TestProviders });
|
||||
});
|
||||
|
||||
|
@ -77,7 +74,7 @@ describe('ConnectorsDropdown', () => {
|
|||
"data-test-subj": "dropdown-connector-servicenow-1",
|
||||
"inputDisplay": <EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
|
@ -88,7 +85,9 @@ describe('ConnectorsDropdown', () => {
|
|||
type="logoSecurity"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<span>
|
||||
My Connector
|
||||
</span>
|
||||
|
@ -100,7 +99,7 @@ describe('ConnectorsDropdown', () => {
|
|||
"data-test-subj": "dropdown-connector-resilient-2",
|
||||
"inputDisplay": <EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
|
@ -111,7 +110,9 @@ describe('ConnectorsDropdown', () => {
|
|||
type="logoSecurity"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<span>
|
||||
My Connector 2
|
||||
</span>
|
||||
|
@ -123,7 +124,7 @@ describe('ConnectorsDropdown', () => {
|
|||
"data-test-subj": "dropdown-connector-jira-1",
|
||||
"inputDisplay": <EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
|
@ -134,7 +135,9 @@ describe('ConnectorsDropdown', () => {
|
|||
type="logoSecurity"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<span>
|
||||
Jira
|
||||
</span>
|
||||
|
@ -146,7 +149,7 @@ describe('ConnectorsDropdown', () => {
|
|||
"data-test-subj": "dropdown-connector-servicenow-sir",
|
||||
"inputDisplay": <EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
|
@ -157,7 +160,9 @@ describe('ConnectorsDropdown', () => {
|
|||
type="logoSecurity"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<span>
|
||||
My Connector SIR
|
||||
</span>
|
||||
|
@ -165,6 +170,43 @@ describe('ConnectorsDropdown', () => {
|
|||
</EuiFlexGroup>,
|
||||
"value": "servicenow-sir",
|
||||
},
|
||||
Object {
|
||||
"data-test-subj": "dropdown-connector-servicenow-legacy",
|
||||
"inputDisplay": <EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<Styled(EuiIcon)
|
||||
size="m"
|
||||
type="logoSecurity"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<span>
|
||||
My Connector
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiIconTip
|
||||
aria-label="Deprecated connector"
|
||||
color="warning"
|
||||
content="Please update your connector"
|
||||
size="m"
|
||||
title="Deprecated connector"
|
||||
type="alert"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>,
|
||||
"value": "servicenow-legacy",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
@ -245,4 +287,13 @@ describe('ConnectorsDropdown', () => {
|
|||
)
|
||||
).not.toThrowError();
|
||||
});
|
||||
|
||||
test('it shows the deprecated tooltip when the connector is legacy', () => {
|
||||
render(<ConnectorsDropdown {...props} selectedConnector="servicenow-legacy" />, {
|
||||
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
|
||||
});
|
||||
|
||||
const tooltips = screen.getAllByLabelText('Deprecated connector');
|
||||
expect(tooltips[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiIconTip, EuiSuperSelect } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { ConnectorTypes } from '../../../common';
|
||||
import { ActionConnector } from '../../containers/configure/types';
|
||||
import * as i18n from './translations';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { getConnectorIcon } from '../utils';
|
||||
import { getConnectorIcon, isLegacyConnector } from '../utils';
|
||||
|
||||
export interface Props {
|
||||
connectors: ActionConnector[];
|
||||
|
@ -79,16 +79,28 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({
|
|||
{
|
||||
value: connector.id,
|
||||
inputDisplay: (
|
||||
<EuiFlexGroup gutterSize="none" alignItems="center" responsive={false}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconExtended
|
||||
type={getConnectorIcon(triggersActionsUi, connector.actionTypeId)}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{connector.name}</span>
|
||||
</EuiFlexItem>
|
||||
{isLegacyConnector(connector) && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
aria-label={i18n.DEPRECATED_TOOLTIP_TITLE}
|
||||
size={ICON_SIZE}
|
||||
type="alert"
|
||||
color="warning"
|
||||
title={i18n.DEPRECATED_TOOLTIP_TITLE}
|
||||
content={i18n.DEPRECATED_TOOLTIP_CONTENT}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
'data-test-subj': `dropdown-connector-${connector.id}`,
|
||||
|
|
|
@ -162,3 +162,17 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string =>
|
|||
values: { connectorName },
|
||||
defaultMessage: 'Update { connectorName }',
|
||||
});
|
||||
|
||||
export const DEPRECATED_TOOLTIP_TITLE = i18n.translate(
|
||||
'xpack.cases.configureCases.deprecatedTooltipTitle',
|
||||
{
|
||||
defaultMessage: 'Deprecated connector',
|
||||
}
|
||||
);
|
||||
|
||||
export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate(
|
||||
'xpack.cases.configureCases.deprecatedTooltipContent',
|
||||
{
|
||||
defaultMessage: 'Please update your connector',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -10,22 +10,18 @@ import { mount } from 'enzyme';
|
|||
|
||||
import { ConnectorTypes } from '../../../common';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { connectors } from '../configure_cases/__mock__';
|
||||
import { ConnectorCard } from './card';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
jest.mock('../../common/lib/kibana');
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
|
||||
describe('ConnectorCard ', () => {
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
connectors.forEach((connector) =>
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
|
||||
});
|
||||
|
||||
it('it does not throw when accessing the icon if the connector type is not registered', () => {
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DeprecatedCallout } from './deprecated_callout';
|
||||
|
||||
describe('DeprecatedCallout', () => {
|
||||
test('it renders correctly', () => {
|
||||
render(<DeprecatedCallout />);
|
||||
expect(screen.getByText('Deprecated connector type')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This connector type is deprecated. Create a new connector or update this connector'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass(
|
||||
'euiCallOut euiCallOut--warning'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders a danger flyout correctly', () => {
|
||||
render(<DeprecatedCallout type="danger" />);
|
||||
expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass(
|
||||
'euiCallOut euiCallOut--danger'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiCallOut, EuiCallOutProps } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate(
|
||||
'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle',
|
||||
{
|
||||
defaultMessage: 'Deprecated connector type',
|
||||
}
|
||||
);
|
||||
|
||||
const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate(
|
||||
'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc',
|
||||
{
|
||||
defaultMessage:
|
||||
'This connector type is deprecated. Create a new connector or update this connector',
|
||||
}
|
||||
);
|
||||
|
||||
interface Props {
|
||||
type?: EuiCallOutProps['color'];
|
||||
}
|
||||
|
||||
const DeprecatedCalloutComponent: React.FC<Props> = ({ type = 'warning' }) => (
|
||||
<EuiCallOut
|
||||
title={LEGACY_CONNECTOR_WARNING_TITLE}
|
||||
color={type}
|
||||
iconType="alert"
|
||||
data-test-subj="legacy-connector-warning-callout"
|
||||
>
|
||||
{LEGACY_CONNECTOR_WARNING_DESC}
|
||||
</EuiCallOut>
|
||||
);
|
||||
|
||||
export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent);
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { waitFor, act } from '@testing-library/react';
|
||||
import { waitFor, act, render, screen } from '@testing-library/react';
|
||||
import { EuiSelect } from '@elastic/eui';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
|
@ -127,6 +127,17 @@ describe('ServiceNowITSM Fields', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('it shows the deprecated callout when the connector is legacy', async () => {
|
||||
const legacyConnector = { ...connector, config: { isLegacy: true } };
|
||||
render(<Fields fields={fields} onChange={onChange} connector={legacyConnector} />);
|
||||
expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it does not show the deprecated callout when the connector is not legacy', async () => {
|
||||
render(<Fields fields={fields} onChange={onChange} connector={connector} />);
|
||||
expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('onChange calls', () => {
|
||||
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ import { ConnectorCard } from '../card';
|
|||
import { useGetChoices } from './use_get_choices';
|
||||
import { Fields, Choice } from './types';
|
||||
import { choicesToEuiOptions } from './helpers';
|
||||
import { connectorValidator } from './validator';
|
||||
import { DeprecatedCallout } from '../deprecated_callout';
|
||||
|
||||
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
|
||||
const defaultFields: Fields = {
|
||||
|
@ -39,6 +41,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
|
|||
} = fields ?? {};
|
||||
const { http, notifications } = useKibana().services;
|
||||
const [choices, setChoices] = useState<Fields>(defaultFields);
|
||||
const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]);
|
||||
|
||||
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
|
||||
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
|
||||
|
@ -149,90 +152,111 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
|
|||
}
|
||||
}, [category, impact, onChange, severity, subcategory, urgency]);
|
||||
|
||||
return isEdit ? (
|
||||
<div data-test-subj={'connector-fields-sn-itsm'}>
|
||||
<EuiFormRow fullWidth label={i18n.URGENCY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="urgencySelect"
|
||||
options={urgencyOptions}
|
||||
value={urgency ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('urgency', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.SEVERITY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="severitySelect"
|
||||
options={severityOptions}
|
||||
value={severity ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('severity', e.target.value)}
|
||||
return (
|
||||
<>
|
||||
{showConnectorWarning && (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<DeprecatedCallout type={isEdit ? 'danger' : 'warning'} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{isEdit ? (
|
||||
<div data-test-subj="connector-fields-sn-itsm">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.URGENCY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="urgencySelect"
|
||||
options={urgencyOptions}
|
||||
value={urgency ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('urgency', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.SEVERITY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="severitySelect"
|
||||
options={severityOptions}
|
||||
value={severity ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('severity', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.IMPACT}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="impactSelect"
|
||||
options={impactOptions}
|
||||
value={impact ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('impact', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.CATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="categorySelect"
|
||||
options={categoryOptions}
|
||||
value={category ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) =>
|
||||
onChange({ ...fields, category: e.target.value, subcategory: null })
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="subcategorySelect"
|
||||
options={subcategoryOptions}
|
||||
// Needs an empty string instead of undefined to select the blank option when changing categories
|
||||
value={subcategory ?? ''}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('subcategory', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
) : (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<ConnectorCard
|
||||
connectorType={ConnectorTypes.serviceNowITSM}
|
||||
title={connector.name}
|
||||
listItems={listItems}
|
||||
isLoading={false}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.IMPACT}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="impactSelect"
|
||||
options={impactOptions}
|
||||
value={impact ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('impact', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.CATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="categorySelect"
|
||||
options={categoryOptions}
|
||||
value={category ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="subcategorySelect"
|
||||
options={subcategoryOptions}
|
||||
// Needs an empty string instead of undefined to select the blank option when changing categories
|
||||
value={subcategory ?? ''}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('subcategory', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
) : (
|
||||
<ConnectorCard
|
||||
connectorType={ConnectorTypes.serviceNowITSM}
|
||||
title={connector.name}
|
||||
listItems={listItems}
|
||||
isLoading={false}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { waitFor, act } from '@testing-library/react';
|
||||
import { waitFor, act, render, screen } from '@testing-library/react';
|
||||
import { EuiSelect } from '@elastic/eui';
|
||||
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
|
@ -68,16 +68,16 @@ describe('ServiceNowSIR Fields', () => {
|
|||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual(
|
||||
'Destination IP: Yes'
|
||||
'Destination IPs: Yes'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual(
|
||||
'Source IP: Yes'
|
||||
'Source IPs: Yes'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual(
|
||||
'Malware URL: Yes'
|
||||
'Malware URLs: Yes'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual(
|
||||
'Malware Hash: Yes'
|
||||
'Malware Hashes: Yes'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual(
|
||||
'Priority: 1 - Critical'
|
||||
|
@ -161,6 +161,17 @@ describe('ServiceNowSIR Fields', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('it shows the deprecated callout when the connector is legacy', async () => {
|
||||
const legacyConnector = { ...connector, config: { isLegacy: true } };
|
||||
render(<Fields fields={fields} onChange={onChange} connector={legacyConnector} />);
|
||||
expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it does not show the deprecated callout when the connector is not legacy', async () => {
|
||||
render(<Fields fields={fields} onChange={onChange} connector={connector} />);
|
||||
expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('onChange calls', () => {
|
||||
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ import { Choice, Fields } from './types';
|
|||
import { choicesToEuiOptions } from './helpers';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { connectorValidator } from './validator';
|
||||
import { DeprecatedCallout } from '../deprecated_callout';
|
||||
|
||||
const useGetChoicesFields = ['category', 'subcategory', 'priority'];
|
||||
const defaultFields: Fields = {
|
||||
|
@ -40,8 +42,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
|
|||
} = fields ?? {};
|
||||
|
||||
const { http, notifications } = useKibana().services;
|
||||
|
||||
const [choices, setChoices] = useState<Fields>(defaultFields);
|
||||
const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]);
|
||||
|
||||
const onChangeCb = useCallback(
|
||||
(
|
||||
|
@ -166,115 +168,132 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
|
|||
}
|
||||
}, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]);
|
||||
|
||||
return isEdit ? (
|
||||
<div data-test-subj={'connector-fields-sn-sir'}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.ALERT_FIELDS_LABEL}>
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="destIpCheckbox"
|
||||
data-test-subj="destIpCheckbox"
|
||||
label={i18n.DEST_IP}
|
||||
checked={destIp ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('destIp', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="sourceIpCheckbox"
|
||||
data-test-subj="sourceIpCheckbox"
|
||||
label={i18n.SOURCE_IP}
|
||||
checked={sourceIp ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('sourceIp', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="malwareUrlCheckbox"
|
||||
data-test-subj="malwareUrlCheckbox"
|
||||
label={i18n.MALWARE_URL}
|
||||
checked={malwareUrl ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('malwareUrl', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="malwareHashCheckbox"
|
||||
data-test-subj="malwareHashCheckbox"
|
||||
label={i18n.MALWARE_HASH}
|
||||
checked={malwareHash ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('malwareHash', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.PRIORITY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="prioritySelect"
|
||||
hasNoInitialSelection
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
options={priorityOptions}
|
||||
value={priority ?? undefined}
|
||||
onChange={(e) => onChangeCb('priority', e.target.value)}
|
||||
return (
|
||||
<>
|
||||
{showConnectorWarning && (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<DeprecatedCallout type={isEdit ? 'danger' : 'warning'} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{isEdit ? (
|
||||
<div data-test-subj="connector-fields-sn-sir">
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.ALERT_FIELDS_LABEL}>
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="destIpCheckbox"
|
||||
data-test-subj="destIpCheckbox"
|
||||
label={i18n.DEST_IP}
|
||||
checked={destIp ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('destIp', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="sourceIpCheckbox"
|
||||
data-test-subj="sourceIpCheckbox"
|
||||
label={i18n.SOURCE_IP}
|
||||
checked={sourceIp ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('sourceIp', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="malwareUrlCheckbox"
|
||||
data-test-subj="malwareUrlCheckbox"
|
||||
label={i18n.MALWARE_URL}
|
||||
checked={malwareUrl ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('malwareUrl', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiCheckbox
|
||||
id="malwareHashCheckbox"
|
||||
data-test-subj="malwareHashCheckbox"
|
||||
label={i18n.MALWARE_HASH}
|
||||
checked={malwareHash ?? false}
|
||||
compressed
|
||||
onChange={(e) => onChangeCb('malwareHash', e.target.checked)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.PRIORITY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="prioritySelect"
|
||||
hasNoInitialSelection
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
options={priorityOptions}
|
||||
value={priority ?? undefined}
|
||||
onChange={(e) => onChangeCb('priority', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.CATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="categorySelect"
|
||||
options={categoryOptions}
|
||||
value={category ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) =>
|
||||
onChange({ ...fields, category: e.target.value, subcategory: null })
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="subcategorySelect"
|
||||
options={subcategoryOptions}
|
||||
// Needs an empty string instead of undefined to select the blank option when changing categories
|
||||
value={subcategory ?? ''}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('subcategory', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
) : (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<ConnectorCard
|
||||
connectorType={ConnectorTypes.serviceNowSIR}
|
||||
title={connector.name}
|
||||
listItems={listItems}
|
||||
isLoading={false}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.CATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="categorySelect"
|
||||
options={categoryOptions}
|
||||
value={category ?? undefined}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="subcategorySelect"
|
||||
options={subcategoryOptions}
|
||||
// Needs an empty string instead of undefined to select the blank option when changing categories
|
||||
value={subcategory ?? ''}
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
hasNoInitialSelection
|
||||
onChange={(e) => onChangeCb('subcategory', e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
) : (
|
||||
<ConnectorCard
|
||||
connectorType={ConnectorTypes.serviceNowITSM}
|
||||
title={connector.name}
|
||||
listItems={listItems}
|
||||
isLoading={false}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -30,11 +30,11 @@ export const CHOICES_API_ERROR = i18n.translate(
|
|||
);
|
||||
|
||||
export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', {
|
||||
defaultMessage: 'Malware URL',
|
||||
defaultMessage: 'Malware URLs',
|
||||
});
|
||||
|
||||
export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', {
|
||||
defaultMessage: 'Malware Hash',
|
||||
defaultMessage: 'Malware Hashes',
|
||||
});
|
||||
|
||||
export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', {
|
||||
|
@ -46,11 +46,11 @@ export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.sub
|
|||
});
|
||||
|
||||
export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', {
|
||||
defaultMessage: 'Source IP',
|
||||
defaultMessage: 'Source IPs',
|
||||
});
|
||||
|
||||
export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', {
|
||||
defaultMessage: 'Destination IP',
|
||||
defaultMessage: 'Destination IPs',
|
||||
});
|
||||
|
||||
export const PRIORITY = i18n.translate(
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { connector } from '../mock';
|
||||
import { connectorValidator } from './validator';
|
||||
|
||||
describe('ServiceNow validator', () => {
|
||||
describe('connectorValidator', () => {
|
||||
test('it returns an error message if the connector is legacy', () => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
isLegacy: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' });
|
||||
});
|
||||
|
||||
test('it does not returns an error message if the connector is not legacy', () => {
|
||||
const invalidConnector = {
|
||||
...connector,
|
||||
config: {
|
||||
...connector.config,
|
||||
isLegacy: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(connectorValidator(invalidConnector)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ValidationConfig } from '../../../common/shared_imports';
|
||||
import { CaseActionConnector } from '../../types';
|
||||
|
||||
/**
|
||||
* The user can not use a legacy connector
|
||||
*/
|
||||
|
||||
export const connectorValidator = (
|
||||
connector: CaseActionConnector
|
||||
): ReturnType<ValidationConfig['validator']> => {
|
||||
const {
|
||||
config: { isLegacy },
|
||||
} = connector;
|
||||
if (isLegacy) {
|
||||
return {
|
||||
message: 'Deprecated connector',
|
||||
};
|
||||
}
|
||||
};
|
|
@ -22,8 +22,8 @@ import { TestProviders } from '../../common/mock';
|
|||
import { useCaseConfigure } from '../../containers/configure/use_configure';
|
||||
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
|
||||
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
|
||||
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
|
||||
import { useKibana } from '../../common/lib/kibana';
|
||||
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
|
||||
|
||||
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
|
||||
|
||||
|
@ -86,14 +86,10 @@ describe('Connector', () => {
|
|||
return <Form form={form}>{children}</Form>;
|
||||
};
|
||||
|
||||
const { createMockActionTypeModel } = actionTypeRegistryMock;
|
||||
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
|
||||
|
||||
beforeAll(() => {
|
||||
connectorsMock.forEach((connector) =>
|
||||
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
|
||||
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
|
||||
)
|
||||
);
|
||||
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -5,6 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ActionConnector } from '../../common';
|
||||
|
||||
export type CaseActionConnector = ActionConnector;
|
||||
export { CaseActionConnector } from '../../common';
|
||||
|
|
|
@ -10,7 +10,13 @@ import { ConnectorTypes } from '../../common';
|
|||
import { FieldConfig, ValidationConfig } from '../common/shared_imports';
|
||||
import { StartPlugins } from '../types';
|
||||
import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator';
|
||||
import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator';
|
||||
import { CaseActionConnector } from './types';
|
||||
import {
|
||||
ENABLE_NEW_SN_ITSM_CONNECTOR,
|
||||
ENABLE_NEW_SN_SIR_CONNECTOR,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from '../../../actions/server/constants/connectors';
|
||||
|
||||
export const getConnectorById = (
|
||||
id: string,
|
||||
|
@ -22,6 +28,8 @@ const validators: Record<
|
|||
(connector: CaseActionConnector) => ReturnType<ValidationConfig['validator']>
|
||||
> = {
|
||||
[ConnectorTypes.swimlane]: swimlaneConnectorValidator,
|
||||
[ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator,
|
||||
[ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator,
|
||||
};
|
||||
|
||||
export const getConnectorsFormValidators = ({
|
||||
|
@ -68,3 +76,20 @@ export const getConnectorIcon = (
|
|||
|
||||
return emptyResponse;
|
||||
};
|
||||
|
||||
// TODO: Remove when the applications are certified
|
||||
export const isLegacyConnector = (connector?: CaseActionConnector) => {
|
||||
if (connector == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return connector.config.isLegacy;
|
||||
};
|
||||
|
|
|
@ -71,6 +71,16 @@ export const connectorsMock: ActionConnector[] = [
|
|||
},
|
||||
isPreconfigured: false,
|
||||
},
|
||||
{
|
||||
id: 'servicenow-legacy',
|
||||
actionTypeId: '.servicenow',
|
||||
name: 'My Connector',
|
||||
config: {
|
||||
apiUrl: 'https://instance1.service-now.com',
|
||||
isLegacy: true,
|
||||
},
|
||||
isPreconfigured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const actionTypesMock: ActionTypeConnector[] = [
|
||||
|
|
|
@ -10,6 +10,7 @@ import { format } from './itsm_format';
|
|||
|
||||
describe('ITSM formatter', () => {
|
||||
const theCase = {
|
||||
id: 'case-id',
|
||||
connector: {
|
||||
fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' },
|
||||
},
|
||||
|
@ -17,7 +18,11 @@ describe('ITSM formatter', () => {
|
|||
|
||||
it('it formats correctly', async () => {
|
||||
const res = await format(theCase, []);
|
||||
expect(res).toEqual(theCase.connector.fields);
|
||||
expect(res).toEqual({
|
||||
...theCase.connector.fields,
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: 'case-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('it formats correctly when fields do not exist ', async () => {
|
||||
|
@ -29,6 +34,8 @@ describe('ITSM formatter', () => {
|
|||
impact: null,
|
||||
category: null,
|
||||
subcategory: null,
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,5 +16,13 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => {
|
|||
category = null,
|
||||
subcategory = null,
|
||||
} = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {};
|
||||
return { severity, urgency, impact, category, subcategory };
|
||||
return {
|
||||
severity,
|
||||
urgency,
|
||||
impact,
|
||||
category,
|
||||
subcategory,
|
||||
correlation_id: theCase.id ?? null,
|
||||
correlation_display: 'Elastic Case',
|
||||
};
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ import { format } from './sir_format';
|
|||
|
||||
describe('ITSM formatter', () => {
|
||||
const theCase = {
|
||||
id: 'case-id',
|
||||
connector: {
|
||||
fields: {
|
||||
destIp: true,
|
||||
|
@ -26,13 +27,15 @@ describe('ITSM formatter', () => {
|
|||
it('it formats correctly without alerts', async () => {
|
||||
const res = await format(theCase, []);
|
||||
expect(res).toEqual({
|
||||
dest_ip: null,
|
||||
source_ip: null,
|
||||
dest_ip: [],
|
||||
source_ip: [],
|
||||
category: 'Denial of Service',
|
||||
subcategory: 'Inbound DDos',
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
malware_hash: [],
|
||||
malware_url: [],
|
||||
priority: '2 - High',
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: 'case-id',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -40,13 +43,15 @@ describe('ITSM formatter', () => {
|
|||
const invalidFields = { connector: { fields: null } } as CaseResponse;
|
||||
const res = await format(invalidFields, []);
|
||||
expect(res).toEqual({
|
||||
dest_ip: null,
|
||||
source_ip: null,
|
||||
dest_ip: [],
|
||||
source_ip: [],
|
||||
category: null,
|
||||
subcategory: null,
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
malware_hash: [],
|
||||
malware_url: [],
|
||||
priority: null,
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -75,14 +80,18 @@ describe('ITSM formatter', () => {
|
|||
];
|
||||
const res = await format(theCase, alerts);
|
||||
expect(res).toEqual({
|
||||
dest_ip: '192.168.1.1,192.168.1.4',
|
||||
source_ip: '192.168.1.2,192.168.1.3',
|
||||
dest_ip: ['192.168.1.1', '192.168.1.4'],
|
||||
source_ip: ['192.168.1.2', '192.168.1.3'],
|
||||
category: 'Denial of Service',
|
||||
subcategory: 'Inbound DDos',
|
||||
malware_hash:
|
||||
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
|
||||
malware_url: 'https://attack.com,https://attack.com/api',
|
||||
malware_hash: [
|
||||
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
||||
'60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
|
||||
],
|
||||
malware_url: ['https://attack.com', 'https://attack.com/api'],
|
||||
priority: '2 - High',
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: 'case-id',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -111,13 +120,15 @@ describe('ITSM formatter', () => {
|
|||
];
|
||||
const res = await format(theCase, alerts);
|
||||
expect(res).toEqual({
|
||||
dest_ip: '192.168.1.1',
|
||||
source_ip: '192.168.1.2,192.168.1.3',
|
||||
dest_ip: ['192.168.1.1'],
|
||||
source_ip: ['192.168.1.2', '192.168.1.3'],
|
||||
category: 'Denial of Service',
|
||||
subcategory: 'Inbound DDos',
|
||||
malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
|
||||
malware_url: 'https://attack.com,https://attack.com/api',
|
||||
malware_hash: ['9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'],
|
||||
malware_url: ['https://attack.com', 'https://attack.com/api'],
|
||||
priority: '2 - High',
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: 'case-id',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -152,13 +163,15 @@ describe('ITSM formatter', () => {
|
|||
|
||||
const res = await format(newCase, alerts);
|
||||
expect(res).toEqual({
|
||||
dest_ip: null,
|
||||
source_ip: '192.168.1.2,192.168.1.3',
|
||||
dest_ip: [],
|
||||
source_ip: ['192.168.1.2', '192.168.1.3'],
|
||||
category: 'Denial of Service',
|
||||
subcategory: 'Inbound DDos',
|
||||
malware_hash: null,
|
||||
malware_url: 'https://attack.com,https://attack.com/api',
|
||||
malware_hash: [],
|
||||
malware_url: ['https://attack.com', 'https://attack.com/api'],
|
||||
priority: '2 - High',
|
||||
correlation_display: 'Elastic Case',
|
||||
correlation_id: 'case-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -32,11 +32,11 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
|
|||
malware_url: new Set(),
|
||||
};
|
||||
|
||||
let sirFields: Record<SirFieldKey, string | null> = {
|
||||
dest_ip: null,
|
||||
source_ip: null,
|
||||
malware_hash: null,
|
||||
malware_url: null,
|
||||
let sirFields: Record<SirFieldKey, string[]> = {
|
||||
dest_ip: [],
|
||||
source_ip: [],
|
||||
malware_hash: [],
|
||||
malware_url: [],
|
||||
};
|
||||
|
||||
const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter(
|
||||
|
@ -44,18 +44,17 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
|
|||
);
|
||||
|
||||
if (fieldsToAdd.length > 0) {
|
||||
sirFields = alerts.reduce<Record<SirFieldKey, string | null>>((acc, alert) => {
|
||||
sirFields = alerts.reduce<Record<SirFieldKey, string[]>>((acc, alert) => {
|
||||
fieldsToAdd.forEach((alertField) => {
|
||||
const field = get(alertFieldMapping[alertField].alertPath, alert);
|
||||
if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) {
|
||||
manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field);
|
||||
acc = {
|
||||
...acc,
|
||||
[alertFieldMapping[alertField].sirFieldKey]: `${
|
||||
acc[alertFieldMapping[alertField].sirFieldKey] != null
|
||||
? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}`
|
||||
: field
|
||||
}`,
|
||||
[alertFieldMapping[alertField].sirFieldKey]: [
|
||||
...acc[alertFieldMapping[alertField].sirFieldKey],
|
||||
field,
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -68,5 +67,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
|
|||
category,
|
||||
subcategory,
|
||||
priority,
|
||||
correlation_id: theCase.id ?? null,
|
||||
correlation_display: 'Elastic Case',
|
||||
};
|
||||
};
|
||||
|
|
|
@ -8,13 +8,18 @@
|
|||
import { ServiceNowITSMFieldsType } from '../../../common';
|
||||
import { ICasesConnector } from '../types';
|
||||
|
||||
export interface ServiceNowSIRFieldsType {
|
||||
dest_ip: string | null;
|
||||
source_ip: string | null;
|
||||
interface CorrelationValues {
|
||||
correlation_id: string | null;
|
||||
correlation_display: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceNowSIRFieldsType extends CorrelationValues {
|
||||
dest_ip: string[] | null;
|
||||
source_ip: string[] | null;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
malware_hash: string | null;
|
||||
malware_url: string | null;
|
||||
malware_hash: string[] | null;
|
||||
malware_url: string[] | null;
|
||||
priority: string | null;
|
||||
}
|
||||
|
||||
|
@ -26,7 +31,9 @@ export type AlertFieldMappingAndValues = Record<
|
|||
|
||||
// ServiceNow ITSM
|
||||
export type ServiceNowITSMCasesConnector = ICasesConnector<ServiceNowITSMFieldsType>;
|
||||
export type ServiceNowITSMFormat = ICasesConnector<ServiceNowITSMFieldsType>['format'];
|
||||
export type ServiceNowITSMFormat = ICasesConnector<
|
||||
ServiceNowITSMFieldsType & CorrelationValues
|
||||
>['format'];
|
||||
export type ServiceNowITSMGetMapping = ICasesConnector<ServiceNowITSMFieldsType>['getMapping'];
|
||||
|
||||
// ServiceNow SIR
|
||||
|
|
|
@ -301,6 +301,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
|
|||
'.swimlane',
|
||||
'.webhook',
|
||||
'.servicenow',
|
||||
'.servicenow-sir',
|
||||
'.jira',
|
||||
'.resilient',
|
||||
'.teams',
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getServiceNowConnector } from '../../objects/case';
|
||||
import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../objects/case';
|
||||
|
||||
import { SERVICE_NOW_MAPPING, TOASTER } from '../../screens/configure_cases';
|
||||
|
||||
|
@ -43,8 +43,16 @@ describe('Cases connectors', () => {
|
|||
id: '123',
|
||||
owner: 'securitySolution',
|
||||
};
|
||||
|
||||
const snConnector = getServiceNowConnector();
|
||||
|
||||
beforeEach(() => {
|
||||
cleanKibana();
|
||||
cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, {
|
||||
statusCode: 200,
|
||||
body: getServiceNowITSMHealthResponse(),
|
||||
});
|
||||
|
||||
cy.intercept('POST', '/api/actions/connector').as('createConnector');
|
||||
cy.intercept('POST', '/api/cases/configure', (req) => {
|
||||
const connector = req.body.connector;
|
||||
|
@ -52,6 +60,7 @@ describe('Cases connectors', () => {
|
|||
res.send(200, { ...configureResult, connector });
|
||||
});
|
||||
}).as('saveConnector');
|
||||
|
||||
cy.intercept('GET', '/api/cases/configure', (req) => {
|
||||
req.reply((res) => {
|
||||
const resBody =
|
||||
|
@ -77,7 +86,7 @@ describe('Cases connectors', () => {
|
|||
loginAndWaitForPageWithoutDateRange(CASES_URL);
|
||||
goToEditExternalConnection();
|
||||
openAddNewConnectorOption();
|
||||
addServiceNowConnector(getServiceNowConnector());
|
||||
addServiceNowConnector(snConnector);
|
||||
|
||||
cy.wait('@createConnector').then(({ response }) => {
|
||||
cy.wrap(response!.statusCode).should('eql', 200);
|
||||
|
|
|
@ -44,6 +44,14 @@ export interface IbmResilientConnectorOptions {
|
|||
incidentTypes: string[];
|
||||
}
|
||||
|
||||
interface ServiceNowHealthResponse {
|
||||
result: {
|
||||
name: string;
|
||||
scope: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const getCase1 = (): TestCase => ({
|
||||
name: 'This is the title of the case',
|
||||
tags: ['Tag1', 'Tag2'],
|
||||
|
@ -60,6 +68,14 @@ export const getServiceNowConnector = (): Connector => ({
|
|||
password: 'password',
|
||||
});
|
||||
|
||||
export const getServiceNowITSMHealthResponse = (): ServiceNowHealthResponse => ({
|
||||
result: {
|
||||
name: 'Elastic',
|
||||
scope: 'x_elas2_inc_int',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
export const getJiraConnectorOptions = (): JiraConnectorOptions => ({
|
||||
issueType: '10006',
|
||||
priority: 'High',
|
||||
|
|
|
@ -25052,7 +25052,6 @@
|
|||
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "選択肢を取得できません",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "緊急",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "ServiceNow ITSMでインシデントを作成します。",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps",
|
||||
|
|
|
@ -25480,7 +25480,6 @@
|
|||
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "无法获取选项",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "紧急性",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "在 ServiceNow ITSM 中创建事件。",
|
||||
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps",
|
||||
|
|
|
@ -35,6 +35,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy();
|
||||
|
@ -66,6 +68,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy();
|
||||
|
@ -99,6 +103,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe(
|
||||
|
@ -132,6 +138,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy();
|
||||
|
@ -165,6 +173,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(true);
|
||||
|
@ -199,6 +209,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(false);
|
||||
|
@ -223,6 +235,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -245,6 +259,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -268,6 +284,8 @@ describe('EmailActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -71,6 +71,8 @@ describe('IndexActionConnectorFields renders', () => {
|
|||
editActionSecrets: () => {},
|
||||
errors: { index: [] },
|
||||
readOnly: false,
|
||||
setCallbacks: () => {},
|
||||
isEdit: false,
|
||||
};
|
||||
const wrapper = mountWithIntl(<IndexActionConnectorFields {...props} />);
|
||||
|
||||
|
|
|
@ -34,6 +34,8 @@ describe('JiraActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -74,6 +76,8 @@ describe('JiraActionConnectorFields renders', () => {
|
|||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
consumer={'case'}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy();
|
||||
|
@ -104,6 +108,8 @@ describe('JiraActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -125,6 +131,8 @@ describe('JiraActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -152,6 +160,8 @@ describe('JiraActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -33,6 +33,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -61,6 +63,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -86,6 +90,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -112,6 +118,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -34,6 +34,8 @@ describe('ResilientActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -74,6 +76,8 @@ describe('ResilientActionConnectorFields renders', () => {
|
|||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
consumer={'case'}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -105,6 +109,8 @@ describe('ResilientActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -126,6 +132,8 @@ describe('ResilientActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -153,6 +161,8 @@ describe('ResilientActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
|
||||
import { getChoices } from './api';
|
||||
import { getChoices, getAppInfo } from './api';
|
||||
|
||||
const choicesResponse = {
|
||||
status: 'ok',
|
||||
|
@ -44,10 +44,27 @@ const choicesResponse = {
|
|||
],
|
||||
};
|
||||
|
||||
const applicationInfoData = {
|
||||
result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' },
|
||||
};
|
||||
|
||||
const applicationInfoResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => applicationInfoData,
|
||||
};
|
||||
|
||||
describe('ServiceNow API', () => {
|
||||
const http = httpServiceMock.createStartContract();
|
||||
let fetchMock: jest.SpyInstance<Promise<unknown>>;
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
beforeAll(() => {
|
||||
fetchMock = jest.spyOn(window, 'fetch');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getChoices', () => {
|
||||
test('should call get choices API', async () => {
|
||||
|
@ -67,4 +84,96 @@ describe('ServiceNow API', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppInfo', () => {
|
||||
test('should call getAppInfo API for ITSM', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
fetchMock.mockResolvedValueOnce(applicationInfoResponse);
|
||||
|
||||
const res = await getAppInfo({
|
||||
signal: abortCtrl.signal,
|
||||
apiUrl: 'https://example.com',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
actionTypeId: '.servicenow',
|
||||
});
|
||||
|
||||
expect(res).toEqual(applicationInfoData.result);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.com/api/x_elas2_inc_int/elastic_api/health',
|
||||
{
|
||||
signal: abortCtrl.signal,
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should call getAppInfo API correctly for SIR', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
fetchMock.mockResolvedValueOnce(applicationInfoResponse);
|
||||
|
||||
const res = await getAppInfo({
|
||||
signal: abortCtrl.signal,
|
||||
apiUrl: 'https://example.com',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
actionTypeId: '.servicenow-sir',
|
||||
});
|
||||
|
||||
expect(res).toEqual(applicationInfoData.result);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.com/api/x_elas2_sir_int/elastic_api/health',
|
||||
{
|
||||
signal: abortCtrl.signal,
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an error when the response fails', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => applicationInfoResponse.json,
|
||||
});
|
||||
|
||||
await expect(() =>
|
||||
getAppInfo({
|
||||
signal: abortCtrl.signal,
|
||||
apiUrl: 'https://example.com',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
actionTypeId: '.servicenow',
|
||||
})
|
||||
).rejects.toThrow('Received status:');
|
||||
});
|
||||
|
||||
it('returns an error when parsing the json fails', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('bad');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(() =>
|
||||
getAppInfo({
|
||||
signal: abortCtrl.signal,
|
||||
apiUrl: 'https://example.com',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
actionTypeId: '.servicenow',
|
||||
})
|
||||
).rejects.toThrow('bad');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
*/
|
||||
|
||||
import { HttpSetup } from 'kibana/public';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config';
|
||||
import { BASE_ACTION_API_PATH } from '../../../constants';
|
||||
import { API_INFO_ERROR } from './translations';
|
||||
import { AppInfo, RESTApiError } from './types';
|
||||
|
||||
export async function getChoices({
|
||||
http,
|
||||
|
@ -29,3 +33,43 @@ export async function getChoices({
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The app info url should be the same as at:
|
||||
* x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
|
||||
*/
|
||||
const getAppInfoUrl = (url: string, scope: string) => `${url}/api/${scope}/elastic_api/health`;
|
||||
|
||||
export async function getAppInfo({
|
||||
signal,
|
||||
apiUrl,
|
||||
username,
|
||||
password,
|
||||
actionTypeId,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
apiUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
actionTypeId: string;
|
||||
}): Promise<AppInfo | RESTApiError> {
|
||||
const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
|
||||
const config = snExternalServiceConfig[actionTypeId];
|
||||
const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash, config.appScope ?? ''), {
|
||||
method: 'GET',
|
||||
signal,
|
||||
headers: {
|
||||
Authorization: 'Basic ' + btoa(username + ':' + password),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(API_INFO_ERROR(response.status));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
...data.result,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ApplicationRequiredCallout } from './application_required_callout';
|
||||
|
||||
describe('ApplicationRequiredCallout', () => {
|
||||
test('it renders the callout', () => {
|
||||
render(<ApplicationRequiredCallout />);
|
||||
expect(screen.getByText('Elastic ServiceNow App not installed')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Please go to the ServiceNow app store and install the application')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders the ServiceNow store button', () => {
|
||||
render(<ApplicationRequiredCallout />);
|
||||
expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders an error message if provided', () => {
|
||||
render(<ApplicationRequiredCallout message="Denied" />);
|
||||
expect(screen.getByText('Error message: Denied')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { SNStoreButton } from './sn_store_button';
|
||||
|
||||
const content = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.content',
|
||||
{
|
||||
defaultMessage: 'Please go to the ServiceNow app store and install the application',
|
||||
}
|
||||
);
|
||||
|
||||
const ERROR_MESSAGE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.errorMessage',
|
||||
{
|
||||
defaultMessage: 'Error message',
|
||||
}
|
||||
);
|
||||
|
||||
interface Props {
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
const ApplicationRequiredCalloutComponent: React.FC<Props> = ({ message }) => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
size="m"
|
||||
iconType="alert"
|
||||
data-test-subj="snDeprecatedCallout"
|
||||
color="danger"
|
||||
title={i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout',
|
||||
{
|
||||
defaultMessage: 'Elastic ServiceNow App not installed',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<p>{content}</p>
|
||||
{message && (
|
||||
<p>
|
||||
{ERROR_MESSAGE}: {message}
|
||||
</p>
|
||||
)}
|
||||
<SNStoreButton color="danger" />
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApplicationRequiredCallout = memo(ApplicationRequiredCalloutComponent);
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}';
|
||||
export const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}';
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiLink,
|
||||
EuiFieldText,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiFieldPassword,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { ActionConnectorFieldsProps } from '../../../../../public/types';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label';
|
||||
import * as i18n from './translations';
|
||||
import { ServiceNowActionConnector } from './types';
|
||||
import { isFieldInvalid } from './helpers';
|
||||
|
||||
interface Props {
|
||||
action: ActionConnectorFieldsProps<ServiceNowActionConnector>['action'];
|
||||
errors: ActionConnectorFieldsProps<ServiceNowActionConnector>['errors'];
|
||||
readOnly: boolean;
|
||||
isLoading: boolean;
|
||||
editActionSecrets: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionSecrets'];
|
||||
editActionConfig: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionConfig'];
|
||||
}
|
||||
|
||||
const CredentialsComponent: React.FC<Props> = ({
|
||||
action,
|
||||
errors,
|
||||
readOnly,
|
||||
isLoading,
|
||||
editActionSecrets,
|
||||
editActionConfig,
|
||||
}) => {
|
||||
const { docLinks } = useKibana().services;
|
||||
const { apiUrl } = action.config;
|
||||
const { username, password } = action.secrets;
|
||||
|
||||
const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl);
|
||||
const isUsernameInvalid = isFieldInvalid(username, errors.username);
|
||||
const isPasswordInvalid = isFieldInvalid(password, errors.password);
|
||||
|
||||
const handleOnChangeActionConfig = useCallback(
|
||||
(key: string, value: string) => editActionConfig(key, value),
|
||||
[editActionConfig]
|
||||
);
|
||||
|
||||
const handleOnChangeSecretConfig = useCallback(
|
||||
(key: string, value: string) => editActionSecrets(key, value),
|
||||
[editActionSecrets]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h4>{i18n.SN_INSTANCE_LABEL}</h4>
|
||||
</EuiTitle>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel"
|
||||
defaultMessage="Please provide the full URL to the desired ServiceNow instance. If you do not have one, you can {instance}"
|
||||
values={{
|
||||
instance: (
|
||||
<EuiLink href={docLinks.links.alerting.serviceNowAction} target="_blank">
|
||||
{i18n.SETUP_DEV_INSTANCE}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="apiUrl"
|
||||
fullWidth
|
||||
error={errors.apiUrl}
|
||||
isInvalid={isApiUrlInvalid}
|
||||
label={i18n.API_URL_LABEL}
|
||||
helpText={i18n.API_URL_HELPTEXT}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
isInvalid={isApiUrlInvalid}
|
||||
name="apiUrl"
|
||||
readOnly={readOnly}
|
||||
value={apiUrl || ''} // Needed to prevent uncontrolled input error when value is undefined
|
||||
data-test-subj="apiUrlFromInput"
|
||||
onChange={(evt) => handleOnChangeActionConfig('apiUrl', evt.target.value)}
|
||||
onBlur={() => {
|
||||
if (!apiUrl) {
|
||||
editActionConfig('apiUrl', '');
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h4>{i18n.AUTHENTICATION_LABEL}</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth>
|
||||
{getEncryptedFieldNotifyLabel(
|
||||
!action.id,
|
||||
2,
|
||||
action.isMissingSecrets ?? false,
|
||||
i18n.REENTER_VALUES_LABEL
|
||||
)}
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="connector-servicenow-username"
|
||||
fullWidth
|
||||
error={errors.username}
|
||||
isInvalid={isUsernameInvalid}
|
||||
label={i18n.USERNAME_LABEL}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
isInvalid={isUsernameInvalid}
|
||||
readOnly={readOnly}
|
||||
name="connector-servicenow-username"
|
||||
value={username || ''} // Needed to prevent uncontrolled input error when value is undefined
|
||||
data-test-subj="connector-servicenow-username-form-input"
|
||||
onChange={(evt) => handleOnChangeSecretConfig('username', evt.target.value)}
|
||||
onBlur={() => {
|
||||
if (!username) {
|
||||
editActionSecrets('username', '');
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="connector-servicenow-password"
|
||||
fullWidth
|
||||
error={errors.password}
|
||||
isInvalid={isPasswordInvalid}
|
||||
label={i18n.PASSWORD_LABEL}
|
||||
>
|
||||
<EuiFieldPassword
|
||||
fullWidth
|
||||
readOnly={readOnly}
|
||||
isInvalid={isPasswordInvalid}
|
||||
name="connector-servicenow-password"
|
||||
value={password || ''} // Needed to prevent uncontrolled input error when value is undefined
|
||||
data-test-subj="connector-servicenow-password-form-input"
|
||||
onChange={(evt) => handleOnChangeSecretConfig('password', evt.target.value)}
|
||||
onBlur={() => {
|
||||
if (!password) {
|
||||
editActionSecrets('password', '');
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Credentials = memo(CredentialsComponent);
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
|
||||
import { DeprecatedCallout } from './deprecated_callout';
|
||||
|
||||
describe('DeprecatedCallout', () => {
|
||||
const onMigrate = jest.fn();
|
||||
|
||||
test('it renders correctly', () => {
|
||||
render(<DeprecatedCallout onMigrate={onMigrate} />, {
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Deprecated connector type')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it calls onMigrate when pressing the button', () => {
|
||||
render(<DeprecatedCallout onMigrate={onMigrate} />, {
|
||||
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onMigrate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
interface Props {
|
||||
onMigrate: () => void;
|
||||
}
|
||||
|
||||
const DeprecatedCalloutComponent: React.FC<Props> = ({ onMigrate }) => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
size="m"
|
||||
iconType="alert"
|
||||
data-test-subj="snDeprecatedCallout"
|
||||
color="warning"
|
||||
title={i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutTitle',
|
||||
{
|
||||
defaultMessage: 'Deprecated connector type',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="This connector type is deprecated. Create a new connector or {migrate}"
|
||||
id="xpack.triggersActionsUI.components.builtinActionTypes.servicenow.appInstallationInfo"
|
||||
values={{
|
||||
migrate: (
|
||||
<EuiButtonEmpty onClick={onMigrate} flush="left">
|
||||
{i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate',
|
||||
{
|
||||
defaultMessage: 'update this connector.',
|
||||
}
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeprecatedCallout = memo(DeprecatedCalloutComponent);
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isRESTApiError, isFieldInvalid } from './helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('isRESTApiError', () => {
|
||||
const resError = { error: { message: 'error', detail: 'access denied' }, status: '401' };
|
||||
|
||||
test('should return true if the error is RESTApiError', async () => {
|
||||
expect(isRESTApiError(resError)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should return true if there is failure status', async () => {
|
||||
// @ts-expect-error
|
||||
expect(isRESTApiError({ status: 'failure' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should return false if there is no error', async () => {
|
||||
// @ts-expect-error
|
||||
expect(isRESTApiError({ whatever: 'test' })).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFieldInvalid', () => {
|
||||
test('should return true if the field is invalid', async () => {
|
||||
expect(isFieldInvalid('description', ['required'])).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should return if false the field is not defined', async () => {
|
||||
expect(isFieldInvalid(undefined, ['required'])).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should return if false the error is not defined', async () => {
|
||||
// @ts-expect-error
|
||||
expect(isFieldInvalid('description', undefined)).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should return if false the error is empty', async () => {
|
||||
expect(isFieldInvalid('description', [])).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,7 +6,38 @@
|
|||
*/
|
||||
|
||||
import { EuiSelectOption } from '@elastic/eui';
|
||||
import { Choice } from './types';
|
||||
import {
|
||||
ENABLE_NEW_SN_ITSM_CONNECTOR,
|
||||
ENABLE_NEW_SN_SIR_CONNECTOR,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from '../../../../../../actions/server/constants/connectors';
|
||||
import { IErrorObject } from '../../../../../public/types';
|
||||
import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types';
|
||||
|
||||
export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
|
||||
choices.map((choice) => ({ value: choice.value, text: choice.label }));
|
||||
|
||||
export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError =>
|
||||
(res as RESTApiError).error != null || (res as RESTApiError).status === 'failure';
|
||||
|
||||
export const isFieldInvalid = (
|
||||
field: string | undefined,
|
||||
error: string | IErrorObject | string[]
|
||||
): boolean => error !== undefined && error.length > 0 && field !== undefined;
|
||||
|
||||
// TODO: Remove when the applications are certified
|
||||
export const isLegacyConnector = (connector: ServiceNowActionConnector) => {
|
||||
if (connector == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return connector.config.isLegacy;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { InstallationCallout } from './installation_callout';
|
||||
|
||||
describe('DeprecatedCallout', () => {
|
||||
test('it renders correctly', () => {
|
||||
render(<InstallationCallout />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'To use this connector, you must first install the Elastic App from the ServiceNow App Store'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders the button', () => {
|
||||
render(<InstallationCallout />);
|
||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { SNStoreButton } from './sn_store_button';
|
||||
|
||||
const InstallationCalloutComponent: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
size="m"
|
||||
iconType="alert"
|
||||
color="warning"
|
||||
data-test-subj="snInstallationCallout"
|
||||
title={i18n.INSTALLATION_CALLOUT_TITLE}
|
||||
>
|
||||
<SNStoreButton color="warning" />
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const InstallationCallout = memo(InstallationCalloutComponent);
|
|
@ -43,6 +43,7 @@ describe('servicenow connector validation', () => {
|
|||
isPreconfigured: false,
|
||||
config: {
|
||||
apiUrl: 'https://dev94428.service-now.com/',
|
||||
isLegacy: false,
|
||||
},
|
||||
} as ServiceNowActionConnector;
|
||||
|
||||
|
@ -50,6 +51,7 @@ describe('servicenow connector validation', () => {
|
|||
config: {
|
||||
errors: {
|
||||
apiUrl: [],
|
||||
isLegacy: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
@ -77,6 +79,7 @@ describe('servicenow connector validation', () => {
|
|||
config: {
|
||||
errors: {
|
||||
apiUrl: ['URL is required.'],
|
||||
isLegacy: [],
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
|
|
|
@ -27,6 +27,7 @@ const validateConnector = async (
|
|||
const translations = await import('./translations');
|
||||
const configErrors = {
|
||||
apiUrl: new Array<string>(),
|
||||
isLegacy: new Array<string>(),
|
||||
};
|
||||
const secretsErrors = {
|
||||
username: new Array<string>(),
|
||||
|
|
|
@ -33,6 +33,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
|
@ -57,8 +59,7 @@ describe('ServiceNowActionConnectorFields renders', () => {
|
|||
name: 'servicenow',
|
||||
config: {
|
||||
apiUrl: 'https://test/',
|
||||
incidentConfiguration: { mapping: [] },
|
||||
isCaseOwned: true,
|
||||
isLegacy: false,
|
||||
},
|
||||
} as ServiceNowActionConnector;
|
||||
const wrapper = mountWithIntl(
|
||||
|
@ -69,6 +70,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
|
|||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
consumer={'case'}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy();
|
||||
|
@ -91,6 +94,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -112,6 +117,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -138,6 +145,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -5,162 +5,142 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiFieldPassword,
|
||||
EuiSpacer,
|
||||
EuiLink,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { ActionConnectorFieldsProps } from '../../../../types';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { ServiceNowActionConnector } from './types';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label';
|
||||
import { DeprecatedCallout } from './deprecated_callout';
|
||||
import { useGetAppInfo } from './use_get_app_info';
|
||||
import { ApplicationRequiredCallout } from './application_required_callout';
|
||||
import { isRESTApiError, isLegacyConnector } from './helpers';
|
||||
import { InstallationCallout } from './installation_callout';
|
||||
import { UpdateConnectorModal } from './update_connector_modal';
|
||||
import { updateActionConnector } from '../../../lib/action_connector_api';
|
||||
import { Credentials } from './credentials';
|
||||
|
||||
const ServiceNowConnectorFields: React.FC<ActionConnectorFieldsProps<ServiceNowActionConnector>> =
|
||||
({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => {
|
||||
const { docLinks } = useKibana().services;
|
||||
({
|
||||
action,
|
||||
editActionSecrets,
|
||||
editActionConfig,
|
||||
errors,
|
||||
consumer,
|
||||
readOnly,
|
||||
setCallbacks,
|
||||
isEdit,
|
||||
}) => {
|
||||
const {
|
||||
http,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
const { apiUrl } = action.config;
|
||||
|
||||
const isApiUrlInvalid: boolean =
|
||||
errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined;
|
||||
|
||||
const { username, password } = action.secrets;
|
||||
const isOldConnector = isLegacyConnector(action);
|
||||
|
||||
const isUsernameInvalid: boolean =
|
||||
errors.username !== undefined && errors.username.length > 0 && username !== undefined;
|
||||
const isPasswordInvalid: boolean =
|
||||
errors.password !== undefined && errors.password.length > 0 && password !== undefined;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleOnChangeActionConfig = useCallback(
|
||||
(key: string, value: string) => editActionConfig(key, value),
|
||||
[editActionConfig]
|
||||
const { fetchAppInfo, isLoading } = useGetAppInfo({
|
||||
actionTypeId: action.actionTypeId,
|
||||
});
|
||||
|
||||
const [applicationRequired, setApplicationRequired] = useState<boolean>(false);
|
||||
const [applicationInfoErrorMsg, setApplicationInfoErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const getApplicationInfo = useCallback(async () => {
|
||||
setApplicationRequired(false);
|
||||
setApplicationInfoErrorMsg(null);
|
||||
|
||||
try {
|
||||
const res = await fetchAppInfo(action);
|
||||
if (isRESTApiError(res)) {
|
||||
throw new Error(res.error?.message ?? i18n.UNKNOWN);
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
setApplicationRequired(true);
|
||||
setApplicationInfoErrorMsg(e.message);
|
||||
// We need to throw here so the connector will be not be saved.
|
||||
throw e;
|
||||
}
|
||||
}, [action, fetchAppInfo]);
|
||||
|
||||
const beforeActionConnectorSave = useCallback(async () => {
|
||||
if (!isOldConnector) {
|
||||
await getApplicationInfo();
|
||||
}
|
||||
}, [getApplicationInfo, isOldConnector]);
|
||||
|
||||
useEffect(
|
||||
() => setCallbacks({ beforeActionConnectorSave }),
|
||||
[beforeActionConnectorSave, setCallbacks]
|
||||
);
|
||||
|
||||
const handleOnChangeSecretConfig = useCallback(
|
||||
(key: string, value: string) => editActionSecrets(key, value),
|
||||
[editActionSecrets]
|
||||
);
|
||||
const onMigrateClick = useCallback(() => setShowModal(true), []);
|
||||
const onModalCancel = useCallback(() => setShowModal(false), []);
|
||||
|
||||
const onModalConfirm = useCallback(async () => {
|
||||
await getApplicationInfo();
|
||||
await updateActionConnector({
|
||||
http,
|
||||
connector: {
|
||||
name: action.name,
|
||||
config: { apiUrl, isLegacy: false },
|
||||
secrets: { username, password },
|
||||
},
|
||||
id: action.id,
|
||||
});
|
||||
|
||||
editActionConfig('isLegacy', false);
|
||||
setShowModal(false);
|
||||
|
||||
toasts.addSuccess({
|
||||
title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name),
|
||||
text: i18n.MIGRATION_SUCCESS_TOAST_TEXT,
|
||||
});
|
||||
}, [
|
||||
getApplicationInfo,
|
||||
http,
|
||||
action.name,
|
||||
action.id,
|
||||
apiUrl,
|
||||
username,
|
||||
password,
|
||||
editActionConfig,
|
||||
toasts,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="apiUrl"
|
||||
fullWidth
|
||||
error={errors.apiUrl}
|
||||
isInvalid={isApiUrlInvalid}
|
||||
label={i18n.API_URL_LABEL}
|
||||
helpText={
|
||||
<EuiLink href={docLinks.links.alerting.serviceNowAction} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel"
|
||||
defaultMessage="Configure a Personal Developer Instance"
|
||||
/>
|
||||
</EuiLink>
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
isInvalid={isApiUrlInvalid}
|
||||
name="apiUrl"
|
||||
readOnly={readOnly}
|
||||
value={apiUrl || ''} // Needed to prevent uncontrolled input error when value is undefined
|
||||
data-test-subj="apiUrlFromInput"
|
||||
onChange={(evt) => handleOnChangeActionConfig('apiUrl', evt.target.value)}
|
||||
onBlur={() => {
|
||||
if (!apiUrl) {
|
||||
editActionConfig('apiUrl', '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h4>{i18n.AUTHENTICATION_LABEL}</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow fullWidth>
|
||||
{getEncryptedFieldNotifyLabel(
|
||||
!action.id,
|
||||
2,
|
||||
action.isMissingSecrets ?? false,
|
||||
i18n.REENTER_VALUES_LABEL
|
||||
)}
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="connector-servicenow-username"
|
||||
fullWidth
|
||||
error={errors.username}
|
||||
isInvalid={isUsernameInvalid}
|
||||
label={i18n.USERNAME_LABEL}
|
||||
>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
isInvalid={isUsernameInvalid}
|
||||
readOnly={readOnly}
|
||||
name="connector-servicenow-username"
|
||||
value={username || ''} // Needed to prevent uncontrolled input error when value is undefined
|
||||
data-test-subj="connector-servicenow-username-form-input"
|
||||
onChange={(evt) => handleOnChangeSecretConfig('username', evt.target.value)}
|
||||
onBlur={() => {
|
||||
if (!username) {
|
||||
editActionSecrets('username', '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
id="connector-servicenow-password"
|
||||
fullWidth
|
||||
error={errors.password}
|
||||
isInvalid={isPasswordInvalid}
|
||||
label={i18n.PASSWORD_LABEL}
|
||||
>
|
||||
<EuiFieldPassword
|
||||
fullWidth
|
||||
readOnly={readOnly}
|
||||
isInvalid={isPasswordInvalid}
|
||||
name="connector-servicenow-password"
|
||||
value={password || ''} // Needed to prevent uncontrolled input error when value is undefined
|
||||
data-test-subj="connector-servicenow-password-form-input"
|
||||
onChange={(evt) => handleOnChangeSecretConfig('password', evt.target.value)}
|
||||
onBlur={() => {
|
||||
if (!password) {
|
||||
editActionSecrets('password', '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{showModal && (
|
||||
<UpdateConnectorModal
|
||||
action={action}
|
||||
applicationInfoErrorMsg={applicationInfoErrorMsg}
|
||||
errors={errors}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
editActionSecrets={editActionSecrets}
|
||||
editActionConfig={editActionConfig}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
/>
|
||||
)}
|
||||
{!isOldConnector && <InstallationCallout />}
|
||||
{isOldConnector && <DeprecatedCallout onMigrate={onMigrateClick} />}
|
||||
<Credentials
|
||||
action={action}
|
||||
errors={errors}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
editActionSecrets={editActionSecrets}
|
||||
editActionConfig={editActionConfig}
|
||||
/>
|
||||
{applicationRequired && !isOldConnector && (
|
||||
<ApplicationRequiredCallout message={applicationInfoErrorMsg} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -31,6 +31,8 @@ const actionParams = {
|
|||
category: 'software',
|
||||
subcategory: 'os',
|
||||
externalId: null,
|
||||
correlation_id: 'alertID',
|
||||
correlation_display: 'Alerting',
|
||||
},
|
||||
comments: [],
|
||||
},
|
||||
|
@ -144,7 +146,10 @@ describe('ServiceNowITSMParamsFields renders', () => {
|
|||
};
|
||||
mount(<ServiceNowITSMParamsFields {...newProps} />);
|
||||
expect(editAction.mock.calls[0][1]).toEqual({
|
||||
incident: {},
|
||||
incident: {
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: '{{rule.id}}:{{alert.id}}',
|
||||
},
|
||||
comments: [],
|
||||
});
|
||||
});
|
||||
|
@ -166,7 +171,10 @@ describe('ServiceNowITSMParamsFields renders', () => {
|
|||
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
|
||||
expect(editAction.mock.calls.length).toEqual(1);
|
||||
expect(editAction.mock.calls[0][1]).toEqual({
|
||||
incident: {},
|
||||
incident: {
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: '{{rule.id}}:{{alert.id}}',
|
||||
},
|
||||
comments: [],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,16 +13,18 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiSwitch,
|
||||
} from '@elastic/eui';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { ActionParamsProps } from '../../../../types';
|
||||
import { ServiceNowITSMActionParams, Choice, Fields } from './types';
|
||||
import { ServiceNowITSMActionParams, Choice, Fields, ServiceNowActionConnector } from './types';
|
||||
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
|
||||
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
|
||||
import { useGetChoices } from './use_get_choices';
|
||||
import { choicesToEuiOptions } from './helpers';
|
||||
import { choicesToEuiOptions, isLegacyConnector } from './helpers';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config';
|
||||
|
||||
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
|
||||
const defaultFields: Fields = {
|
||||
|
@ -42,6 +44,8 @@ const ServiceNowParamsFields: React.FunctionComponent<
|
|||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector);
|
||||
|
||||
const actionConnectorRef = useRef(actionConnector?.id ?? '');
|
||||
const { incident, comments } = useMemo(
|
||||
() =>
|
||||
|
@ -53,8 +57,13 @@ const ServiceNowParamsFields: React.FunctionComponent<
|
|||
[actionParams.subActionParams]
|
||||
);
|
||||
|
||||
const hasUpdateIncident =
|
||||
incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE;
|
||||
const [updateIncident, setUpdateIncident] = useState<boolean>(hasUpdateIncident);
|
||||
const [choices, setChoices] = useState<Fields>(defaultFields);
|
||||
|
||||
const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE;
|
||||
|
||||
const editSubActionProperty = useCallback(
|
||||
(key: string, value: any) => {
|
||||
const newProps =
|
||||
|
@ -90,6 +99,14 @@ const ServiceNowParamsFields: React.FunctionComponent<
|
|||
);
|
||||
}, []);
|
||||
|
||||
const onUpdateIncidentSwitchChange = useCallback(() => {
|
||||
const newCorrelationID = !updateIncident
|
||||
? UPDATE_INCIDENT_VARIABLE
|
||||
: NOT_UPDATE_INCIDENT_VARIABLE;
|
||||
editSubActionProperty('correlation_id', newCorrelationID);
|
||||
setUpdateIncident(!updateIncident);
|
||||
}, [editSubActionProperty, updateIncident]);
|
||||
|
||||
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
|
||||
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
|
||||
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
|
||||
|
@ -119,7 +136,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
|
|||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: {},
|
||||
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
|
||||
comments: [],
|
||||
},
|
||||
index
|
||||
|
@ -136,7 +153,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
|
|||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: {},
|
||||
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
|
||||
comments: [],
|
||||
},
|
||||
index
|
||||
|
@ -236,25 +253,43 @@ const ServiceNowParamsFields: React.FunctionComponent<
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
error={errors['subActionParams.incident.short_description']}
|
||||
isInvalid={
|
||||
errors['subActionParams.incident.short_description'] !== undefined &&
|
||||
errors['subActionParams.incident.short_description'].length > 0 &&
|
||||
incident.short_description !== undefined
|
||||
}
|
||||
label={i18n.SHORT_DESCRIPTION_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'short_description'}
|
||||
inputTargetValue={incident?.short_description ?? undefined}
|
||||
errors={errors['subActionParams.incident.short_description'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
error={errors['subActionParams.incident.short_description']}
|
||||
isInvalid={
|
||||
errors['subActionParams.incident.short_description'] !== undefined &&
|
||||
errors['subActionParams.incident.short_description'].length > 0 &&
|
||||
incident.short_description !== undefined
|
||||
}
|
||||
label={i18n.SHORT_DESCRIPTION_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'short_description'}
|
||||
inputTargetValue={incident?.short_description ?? undefined}
|
||||
errors={errors['subActionParams.incident.short_description'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
{!isOldConnector && (
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow id="update-incident-form-row" fullWidth label={i18n.UPDATE_INCIDENT_LABEL}>
|
||||
<EuiSwitch
|
||||
label={updateIncident ? i18n.ON : i18n.OFF}
|
||||
name="update-incident-switch"
|
||||
checked={updateIncident}
|
||||
onChange={onUpdateIncidentSwitchChange}
|
||||
aria-describedby="update-incident-form-row"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
|
|
|
@ -33,6 +33,8 @@ const actionParams = {
|
|||
priority: '1',
|
||||
subcategory: '20',
|
||||
externalId: null,
|
||||
correlation_id: 'alertID',
|
||||
correlation_display: 'Alerting',
|
||||
},
|
||||
comments: [],
|
||||
},
|
||||
|
@ -174,7 +176,10 @@ describe('ServiceNowSIRParamsFields renders', () => {
|
|||
};
|
||||
mount(<ServiceNowSIRParamsFields {...newProps} />);
|
||||
expect(editAction.mock.calls[0][1]).toEqual({
|
||||
incident: {},
|
||||
incident: {
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: '{{rule.id}}:{{alert.id}}',
|
||||
},
|
||||
comments: [],
|
||||
});
|
||||
});
|
||||
|
@ -196,7 +201,10 @@ describe('ServiceNowSIRParamsFields renders', () => {
|
|||
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
|
||||
expect(editAction.mock.calls.length).toEqual(1);
|
||||
expect(editAction.mock.calls[0][1]).toEqual({
|
||||
incident: {},
|
||||
incident: {
|
||||
correlation_display: 'Alerting',
|
||||
correlation_id: '{{rule.id}}:{{alert.id}}',
|
||||
},
|
||||
comments: [],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiSwitch,
|
||||
} from '@elastic/eui';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { ActionParamsProps } from '../../../../types';
|
||||
|
@ -21,8 +22,9 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var
|
|||
|
||||
import * as i18n from './translations';
|
||||
import { useGetChoices } from './use_get_choices';
|
||||
import { ServiceNowSIRActionParams, Fields, Choice } from './types';
|
||||
import { choicesToEuiOptions } from './helpers';
|
||||
import { ServiceNowSIRActionParams, Fields, Choice, ServiceNowActionConnector } from './types';
|
||||
import { choicesToEuiOptions, isLegacyConnector } from './helpers';
|
||||
import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config';
|
||||
|
||||
const useGetChoicesFields = ['category', 'subcategory', 'priority'];
|
||||
const defaultFields: Fields = {
|
||||
|
@ -31,6 +33,14 @@ const defaultFields: Fields = {
|
|||
priority: [],
|
||||
};
|
||||
|
||||
const valuesToString = (value: string | string[] | null): string | undefined => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(',');
|
||||
}
|
||||
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
const ServiceNowSIRParamsFields: React.FunctionComponent<
|
||||
ActionParamsProps<ServiceNowSIRActionParams>
|
||||
> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => {
|
||||
|
@ -39,6 +49,8 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector);
|
||||
|
||||
const actionConnectorRef = useRef(actionConnector?.id ?? '');
|
||||
const { incident, comments } = useMemo(
|
||||
() =>
|
||||
|
@ -50,8 +62,13 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
[actionParams.subActionParams]
|
||||
);
|
||||
|
||||
const hasUpdateIncident =
|
||||
incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE;
|
||||
const [updateIncident, setUpdateIncident] = useState<boolean>(hasUpdateIncident);
|
||||
const [choices, setChoices] = useState<Fields>(defaultFields);
|
||||
|
||||
const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE;
|
||||
|
||||
const editSubActionProperty = useCallback(
|
||||
(key: string, value: any) => {
|
||||
const newProps =
|
||||
|
@ -87,6 +104,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
);
|
||||
}, []);
|
||||
|
||||
const onUpdateIncidentSwitchChange = useCallback(() => {
|
||||
const newCorrelationID = !updateIncident
|
||||
? UPDATE_INCIDENT_VARIABLE
|
||||
: NOT_UPDATE_INCIDENT_VARIABLE;
|
||||
editSubActionProperty('correlation_id', newCorrelationID);
|
||||
setUpdateIncident(!updateIncident);
|
||||
}, [editSubActionProperty, updateIncident]);
|
||||
|
||||
const { isLoading: isLoadingChoices } = useGetChoices({
|
||||
http,
|
||||
toastNotifications: toasts,
|
||||
|
@ -115,7 +140,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: {},
|
||||
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
|
||||
comments: [],
|
||||
},
|
||||
index
|
||||
|
@ -132,7 +157,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
editAction(
|
||||
'subActionParams',
|
||||
{
|
||||
incident: {},
|
||||
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
|
||||
comments: [],
|
||||
},
|
||||
index
|
||||
|
@ -162,48 +187,48 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'short_description'}
|
||||
inputTargetValue={incident?.short_description ?? undefined}
|
||||
inputTargetValue={incident?.short_description}
|
||||
errors={errors['subActionParams.incident.short_description'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow fullWidth label={i18n.SOURCE_IP_LABEL}>
|
||||
<EuiFormRow fullWidth label={i18n.SOURCE_IP_LABEL} helpText={i18n.SOURCE_IP_HELP_TEXT}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'source_ip'}
|
||||
inputTargetValue={incident?.source_ip ?? undefined}
|
||||
inputTargetValue={valuesToString(incident?.source_ip)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow fullWidth label={i18n.DEST_IP_LABEL}>
|
||||
<EuiFormRow fullWidth label={i18n.DEST_IP_LABEL} helpText={i18n.DEST_IP_HELP_TEXT}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'dest_ip'}
|
||||
inputTargetValue={incident?.dest_ip ?? undefined}
|
||||
inputTargetValue={valuesToString(incident?.dest_ip)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow fullWidth label={i18n.MALWARE_URL_LABEL}>
|
||||
<EuiFormRow fullWidth label={i18n.MALWARE_URL_LABEL} helpText={i18n.MALWARE_URL_HELP_TEXT}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'malware_url'}
|
||||
inputTargetValue={incident?.malware_url ?? undefined}
|
||||
inputTargetValue={valuesToString(incident?.malware_url)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow fullWidth label={i18n.MALWARE_HASH_LABEL}>
|
||||
<EuiFormRow fullWidth label={i18n.MALWARE_HASH_LABEL} helpText={i18n.MALWARE_HASH_HELP_TEXT}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'malware_hash'}
|
||||
inputTargetValue={incident?.malware_hash ?? undefined}
|
||||
inputTargetValue={valuesToString(incident?.malware_hash)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
|
@ -277,6 +302,18 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
|
||||
label={i18n.COMMENTS_LABEL}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
{!isOldConnector && (
|
||||
<EuiFormRow id="update-incident-form-row" fullWidth label={i18n.UPDATE_INCIDENT_LABEL}>
|
||||
<EuiSwitch
|
||||
label={updateIncident ? i18n.ON : i18n.OFF}
|
||||
name="update-incident-switch"
|
||||
checked={updateIncident}
|
||||
onChange={onUpdateIncidentSwitchChange}
|
||||
aria-describedby="update-incident-form-row"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SNStoreButton } from './sn_store_button';
|
||||
|
||||
describe('SNStoreButton', () => {
|
||||
test('it renders the button', () => {
|
||||
render(<SNStoreButton color="warning" />);
|
||||
expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders a danger button', () => {
|
||||
render(<SNStoreButton color="danger" />);
|
||||
expect(screen.getByRole('link')).toHaveClass('euiButton--danger');
|
||||
});
|
||||
|
||||
test('it renders with correct href', () => {
|
||||
render(<SNStoreButton color="warning" />);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { EuiButtonProps, EuiButton } from '@elastic/eui';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
const STORE_URL = 'https://store.servicenow.com/';
|
||||
|
||||
interface Props {
|
||||
color: EuiButtonProps['color'];
|
||||
}
|
||||
|
||||
const SNStoreButtonComponent: React.FC<Props> = ({ color }) => {
|
||||
return (
|
||||
<EuiButton href={STORE_URL} color={color} iconSide="right" iconType="popout">
|
||||
{i18n.VISIT_SN_STORE}
|
||||
</EuiButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const SNStoreButton = memo(SNStoreButtonComponent);
|
|
@ -10,7 +10,14 @@ import { i18n } from '@kbn/i18n';
|
|||
export const API_URL_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel',
|
||||
{
|
||||
defaultMessage: 'URL',
|
||||
defaultMessage: 'ServiceNow instance URL',
|
||||
}
|
||||
);
|
||||
|
||||
export const API_URL_HELPTEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText',
|
||||
{
|
||||
defaultMessage: 'Include the full URL',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -53,7 +60,7 @@ export const REMEMBER_VALUES_LABEL = i18n.translate(
|
|||
export const REENTER_VALUES_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel',
|
||||
{
|
||||
defaultMessage: 'Username and password are encrypted. Please reenter values for these fields.',
|
||||
defaultMessage: 'You will need to re-authenticate each time you edit the connector',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -95,14 +102,28 @@ export const TITLE_REQUIRED = i18n.translate(
|
|||
export const SOURCE_IP_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle',
|
||||
{
|
||||
defaultMessage: 'Source IP',
|
||||
defaultMessage: 'Source IPs',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_IP_HELP_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPHelpText',
|
||||
{
|
||||
defaultMessage: 'List of source IPs (comma, or pipe delimited)',
|
||||
}
|
||||
);
|
||||
|
||||
export const DEST_IP_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle',
|
||||
{
|
||||
defaultMessage: 'Destination IP',
|
||||
defaultMessage: 'Destination IPs',
|
||||
}
|
||||
);
|
||||
|
||||
export const DEST_IP_HELP_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destIPHelpText',
|
||||
{
|
||||
defaultMessage: 'List of destination IPs (comma, or pipe delimited)',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -137,14 +158,28 @@ export const COMMENTS_LABEL = i18n.translate(
|
|||
export const MALWARE_URL_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle',
|
||||
{
|
||||
defaultMessage: 'Malware URL',
|
||||
defaultMessage: 'Malware URLs',
|
||||
}
|
||||
);
|
||||
|
||||
export const MALWARE_URL_HELP_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLHelpText',
|
||||
{
|
||||
defaultMessage: 'List of malware URLs (comma, or pipe delimited)',
|
||||
}
|
||||
);
|
||||
|
||||
export const MALWARE_HASH_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle',
|
||||
{
|
||||
defaultMessage: 'Malware Hash',
|
||||
defaultMessage: 'Malware Hashes',
|
||||
}
|
||||
);
|
||||
|
||||
export const MALWARE_HASH_HELP_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText',
|
||||
{
|
||||
defaultMessage: 'List of malware hashes (comma, or pipe delimited)',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -196,3 +231,91 @@ export const PRIORITY_LABEL = i18n.translate(
|
|||
defaultMessage: 'Priority',
|
||||
}
|
||||
);
|
||||
|
||||
export const API_INFO_ERROR = (status: number) =>
|
||||
i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiInfoError', {
|
||||
values: { status },
|
||||
defaultMessage: 'Received status: {status} when attempting to get application information',
|
||||
});
|
||||
|
||||
export const INSTALL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.install',
|
||||
{
|
||||
defaultMessage: 'install',
|
||||
}
|
||||
);
|
||||
|
||||
export const INSTALLATION_CALLOUT_TITLE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle',
|
||||
{
|
||||
defaultMessage:
|
||||
'To use this connector, you must first install the Elastic App from the ServiceNow App Store',
|
||||
}
|
||||
);
|
||||
|
||||
export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) =>
|
||||
i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle',
|
||||
{
|
||||
defaultMessage: 'Migrated connector {connectorName}',
|
||||
values: {
|
||||
connectorName,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText',
|
||||
{
|
||||
defaultMessage: 'Connector has been successfully migrated.',
|
||||
}
|
||||
);
|
||||
|
||||
export const VISIT_SN_STORE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.visitSNStore',
|
||||
{
|
||||
defaultMessage: 'Visit ServiceNow app store',
|
||||
}
|
||||
);
|
||||
|
||||
export const SETUP_DEV_INSTANCE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.setupDevInstance',
|
||||
{
|
||||
defaultMessage: 'setup a developer instance',
|
||||
}
|
||||
);
|
||||
|
||||
export const SN_INSTANCE_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.snInstanceLabel',
|
||||
{
|
||||
defaultMessage: 'ServiceNow instance',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNKNOWN = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unknown',
|
||||
{
|
||||
defaultMessage: 'UNKNOWN',
|
||||
}
|
||||
);
|
||||
|
||||
export const UPDATE_INCIDENT_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentCheckboxLabel',
|
||||
{
|
||||
defaultMessage: 'Update incident',
|
||||
}
|
||||
);
|
||||
|
||||
export const ON = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOn',
|
||||
{
|
||||
defaultMessage: 'On',
|
||||
}
|
||||
);
|
||||
|
||||
export const OFF = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOff',
|
||||
{
|
||||
defaultMessage: 'Off',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -29,6 +29,7 @@ export interface ServiceNowSIRActionParams {
|
|||
|
||||
export interface ServiceNowConfig {
|
||||
apiUrl: string;
|
||||
isLegacy: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceNowSecrets {
|
||||
|
@ -44,3 +45,17 @@ export interface Choice {
|
|||
}
|
||||
|
||||
export type Fields = Record<string, Choice[]>;
|
||||
export interface AppInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RESTApiError {
|
||||
error: {
|
||||
message: string;
|
||||
detail: string;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiCallOut,
|
||||
EuiTextColor,
|
||||
EuiHorizontalRule,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ActionConnectorFieldsProps } from '../../../../../public/types';
|
||||
import { ServiceNowActionConnector } from './types';
|
||||
import { Credentials } from './credentials';
|
||||
import { isFieldInvalid } from './helpers';
|
||||
import { ApplicationRequiredCallout } from './application_required_callout';
|
||||
|
||||
const title = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle',
|
||||
{
|
||||
defaultMessage: 'Update ServiceNow connector',
|
||||
}
|
||||
);
|
||||
|
||||
const cancelButtonText = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText',
|
||||
{
|
||||
defaultMessage: 'Cancel',
|
||||
}
|
||||
);
|
||||
|
||||
const confirmButtonText = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText',
|
||||
{
|
||||
defaultMessage: 'Update',
|
||||
}
|
||||
);
|
||||
|
||||
const calloutTitle = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle',
|
||||
{
|
||||
defaultMessage:
|
||||
'The Elastic App from the ServiceNow App Store must be installed prior to running the update.',
|
||||
}
|
||||
);
|
||||
|
||||
const warningMessage = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage',
|
||||
{
|
||||
defaultMessage: 'This will update all instances of this connector. This can not be reversed.',
|
||||
}
|
||||
);
|
||||
|
||||
interface Props {
|
||||
action: ActionConnectorFieldsProps<ServiceNowActionConnector>['action'];
|
||||
applicationInfoErrorMsg: string | null;
|
||||
errors: ActionConnectorFieldsProps<ServiceNowActionConnector>['errors'];
|
||||
isLoading: boolean;
|
||||
readOnly: boolean;
|
||||
editActionSecrets: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionSecrets'];
|
||||
editActionConfig: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionConfig'];
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const UpdateConnectorModalComponent: React.FC<Props> = ({
|
||||
action,
|
||||
applicationInfoErrorMsg,
|
||||
errors,
|
||||
isLoading,
|
||||
readOnly,
|
||||
editActionSecrets,
|
||||
editActionConfig,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { apiUrl } = action.config;
|
||||
const { username, password } = action.secrets;
|
||||
|
||||
const hasErrorsOrEmptyFields =
|
||||
apiUrl === undefined ||
|
||||
username === undefined ||
|
||||
password === undefined ||
|
||||
isFieldInvalid(apiUrl, errors.apiUrl) ||
|
||||
isFieldInvalid(username, errors.username) ||
|
||||
isFieldInvalid(password, errors.password);
|
||||
|
||||
return (
|
||||
<EuiModal onClose={onCancel}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<h1>{title}</h1>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiCallOut
|
||||
size="m"
|
||||
iconType="alert"
|
||||
data-test-subj="snModalInstallationCallout"
|
||||
title={calloutTitle}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<Credentials
|
||||
action={action}
|
||||
errors={errors}
|
||||
readOnly={readOnly}
|
||||
isLoading={isLoading}
|
||||
editActionSecrets={editActionSecrets}
|
||||
editActionConfig={editActionConfig}
|
||||
/>
|
||||
<EuiHorizontalRule />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiTextColor color="danger">{warningMessage}</EuiTextColor>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
{applicationInfoErrorMsg && (
|
||||
<ApplicationRequiredCallout message={applicationInfoErrorMsg} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onCancel}>{cancelButtonText}</EuiButtonEmpty>
|
||||
<EuiButton
|
||||
onClick={onConfirm}
|
||||
color="danger"
|
||||
fill
|
||||
disabled={hasErrorsOrEmptyFields}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpdateConnectorModal = memo(UpdateConnectorModalComponent);
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
import { useGetAppInfo, UseGetAppInfo, UseGetAppInfoProps } from './use_get_app_info';
|
||||
import { getAppInfo } from './api';
|
||||
import { ServiceNowActionConnector } from './types';
|
||||
|
||||
jest.mock('./api');
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const getAppInfoMock = getAppInfo as jest.Mock;
|
||||
|
||||
const actionTypeId = '.servicenow';
|
||||
const applicationInfoData = {
|
||||
name: 'Elastic',
|
||||
scope: 'x_elas2_inc_int',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.servicenow',
|
||||
name: 'ServiceNow ITSM',
|
||||
isPreconfigured: false,
|
||||
config: {
|
||||
apiUrl: 'https://test.service-now.com/',
|
||||
isLegacy: false,
|
||||
},
|
||||
} as ServiceNowActionConnector;
|
||||
|
||||
describe('useGetAppInfo', () => {
|
||||
getAppInfoMock.mockResolvedValue(applicationInfoData);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('init', async () => {
|
||||
const { result } = renderHook<UseGetAppInfoProps, UseGetAppInfo>(() =>
|
||||
useGetAppInfo({
|
||||
actionTypeId,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
fetchAppInfo: result.current.fetchAppInfo,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the application information', async () => {
|
||||
const { result } = renderHook<UseGetAppInfoProps, UseGetAppInfo>(() =>
|
||||
useGetAppInfo({
|
||||
actionTypeId,
|
||||
})
|
||||
);
|
||||
|
||||
let res;
|
||||
|
||||
await act(async () => {
|
||||
res = await result.current.fetchAppInfo(actionConnector);
|
||||
});
|
||||
|
||||
expect(res).toEqual(applicationInfoData);
|
||||
});
|
||||
|
||||
it('it throws an error when api fails', async () => {
|
||||
expect.assertions(1);
|
||||
getAppInfoMock.mockImplementation(() => {
|
||||
throw new Error('An error occurred');
|
||||
});
|
||||
|
||||
const { result } = renderHook<UseGetAppInfoProps, UseGetAppInfo>(() =>
|
||||
useGetAppInfo({
|
||||
actionTypeId,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(() =>
|
||||
act(async () => {
|
||||
await result.current.fetchAppInfo(actionConnector);
|
||||
})
|
||||
).rejects.toThrow('An error occurred');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { getAppInfo } from './api';
|
||||
import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types';
|
||||
|
||||
export interface UseGetAppInfoProps {
|
||||
actionTypeId: string;
|
||||
}
|
||||
|
||||
export interface UseGetAppInfo {
|
||||
fetchAppInfo: (connector: ServiceNowActionConnector) => Promise<AppInfo | RESTApiError>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const useGetAppInfo = ({ actionTypeId }: UseGetAppInfoProps): UseGetAppInfo => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const didCancel = useRef(false);
|
||||
const abortCtrl = useRef(new AbortController());
|
||||
|
||||
const fetchAppInfo = useCallback(
|
||||
async (connector) => {
|
||||
try {
|
||||
didCancel.current = false;
|
||||
abortCtrl.current.abort();
|
||||
abortCtrl.current = new AbortController();
|
||||
setIsLoading(true);
|
||||
|
||||
const res = await getAppInfo({
|
||||
signal: abortCtrl.current.signal,
|
||||
apiUrl: connector.config.apiUrl,
|
||||
username: connector.secrets.username,
|
||||
password: connector.secrets.password,
|
||||
actionTypeId,
|
||||
});
|
||||
|
||||
if (!didCancel.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
if (!didCancel.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[actionTypeId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
didCancel.current = true;
|
||||
abortCtrl.current.abort();
|
||||
setIsLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
fetchAppInfo,
|
||||
isLoading,
|
||||
};
|
||||
};
|
|
@ -30,6 +30,8 @@ describe('SlackActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -56,6 +58,8 @@ describe('SlackActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -76,6 +80,8 @@ describe('SlackActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -98,6 +104,8 @@ describe('SlackActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -39,29 +39,28 @@ describe('Swimlane API', () => {
|
|||
});
|
||||
|
||||
it('returns an error when the response fails', async () => {
|
||||
expect.assertions(1);
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => getApplicationResponse,
|
||||
});
|
||||
|
||||
try {
|
||||
await getApplication({
|
||||
await expect(() =>
|
||||
getApplication({
|
||||
signal: abortCtrl.signal,
|
||||
apiToken: '',
|
||||
appId: '',
|
||||
url: '',
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('Received status:');
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Received status:');
|
||||
});
|
||||
|
||||
it('returns an error when parsing the json fails', async () => {
|
||||
const abortCtrl = new AbortController();
|
||||
expect.assertions(1);
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
@ -70,16 +69,14 @@ describe('Swimlane API', () => {
|
|||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await getApplication({
|
||||
await expect(() =>
|
||||
getApplication({
|
||||
signal: abortCtrl.signal,
|
||||
apiToken: '',
|
||||
appId: '',
|
||||
url: '',
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('bad');
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('bad');
|
||||
});
|
||||
|
||||
it('it removes unsafe fields', async () => {
|
||||
|
|
|
@ -50,6 +50,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -77,6 +79,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -106,6 +110,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -139,6 +145,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -184,6 +192,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -229,6 +239,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -285,6 +297,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -30,6 +30,8 @@ describe('TeamsActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -56,6 +58,8 @@ describe('TeamsActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -79,6 +83,8 @@ describe('TeamsActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -103,6 +109,8 @@ describe('TeamsActionFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -35,6 +35,8 @@ describe('WebhookActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy();
|
||||
|
@ -62,6 +64,8 @@ describe('WebhookActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -92,6 +96,8 @@ describe('WebhookActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
|
||||
|
@ -123,6 +129,8 @@ describe('WebhookActionConnectorFields renders', () => {
|
|||
editActionConfig={() => {}}
|
||||
editActionSecrets={() => {}}
|
||||
readOnly={false}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
|
||||
|
|
|
@ -49,6 +49,8 @@ describe('action_connector_form', () => {
|
|||
dispatch={() => {}}
|
||||
errors={{ name: [] }}
|
||||
actionTypeRegistry={actionTypeRegistry}
|
||||
setCallbacks={() => {}}
|
||||
isEdit={false}
|
||||
/>
|
||||
);
|
||||
const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]');
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue