mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Cases][Connectors] ServiceNow ITOM: MVP (#114125)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
83f12a9d82
commit
20b11c9f43
52 changed files with 1956 additions and 80 deletions
|
@ -43,6 +43,10 @@ a| <<servicenow-sir-action-type, ServiceNow SecOps>>
|
|||
|
||||
| Create a security incident in ServiceNow.
|
||||
|
||||
a| <<servicenow-itom-action-type, ServiceNow ITOM>>
|
||||
|
||||
| Create an event in ServiceNow.
|
||||
|
||||
a| <<slack-action-type, Slack>>
|
||||
|
||||
| Send a message to a Slack channel or user.
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
[role="xpack"]
|
||||
[[servicenow-itom-action-type]]
|
||||
=== ServiceNow connector and action
|
||||
++++
|
||||
<titleabbrev>ServiceNow ITOM</titleabbrev>
|
||||
++++
|
||||
|
||||
The ServiceNow ITOM connector uses the https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[Event API] to create ServiceNow events.
|
||||
|
||||
[float]
|
||||
[[servicenow-itom-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
ServiceNow ITOM 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 Event 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-itom-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-itom-configuration]]
|
||||
==== Preconfigured connector type
|
||||
|
||||
[source,text]
|
||||
--
|
||||
my-servicenow-itom:
|
||||
name: preconfigured-servicenow-connector-type
|
||||
actionTypeId: .servicenow-itom
|
||||
config:
|
||||
apiUrl: https://example.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-itom-ui]]
|
||||
==== Define connector in Stack Management
|
||||
|
||||
Define ServiceNow ITOM connector properties.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/servicenow-itom-connector.png[ServiceNow ITOM connector]
|
||||
|
||||
Test ServiceNow ITOM action parameters.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/servicenow-itom-params-test.png[ServiceNow ITOM params test]
|
||||
|
||||
[float]
|
||||
[[servicenow-itom-action-configuration]]
|
||||
==== Action configuration
|
||||
|
||||
ServiceNow ITOM actions have the following configuration properties.
|
||||
|
||||
Source:: The name of the event source type.
|
||||
Node:: The Host that the event was triggered for.
|
||||
Type:: The type of event.
|
||||
Resource:: The name of the resource.
|
||||
Metric name:: Name of the metric.
|
||||
Source instance (event_class):: Specific instance of the source.
|
||||
Message key:: All actions sharing this key will be associated with the same ServiceNow alert. Default value: `<rule ID>:<alert instance ID>`.
|
||||
Severity:: The severity of the event.
|
||||
Description:: The details about the event.
|
||||
|
||||
Refer to https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[ServiceNow documentation] for more information about the properties.
|
||||
|
||||
[float]
|
||||
[[configuring-servicenow-itom]]
|
||||
==== Configure ServiceNow ITOM
|
||||
|
||||
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.
|
|
@ -36,7 +36,7 @@ Use the <<action-settings, Action configuration settings>> to customize connecto
|
|||
name: preconfigured-servicenow-connector-type
|
||||
actionTypeId: .servicenow-sir
|
||||
config:
|
||||
apiUrl: https://dev94428.service-now.com/
|
||||
apiUrl: https://example.service-now.com/
|
||||
secrets:
|
||||
username: testuser
|
||||
password: passwordkeystorevalue
|
||||
|
|
|
@ -36,7 +36,7 @@ Use the <<action-settings, Action configuration settings>> to customize connecto
|
|||
name: preconfigured-servicenow-connector-type
|
||||
actionTypeId: .servicenow
|
||||
config:
|
||||
apiUrl: https://dev94428.service-now.com/
|
||||
apiUrl: https://example.service-now.com/
|
||||
secrets:
|
||||
username: testuser
|
||||
password: passwordkeystorevalue
|
||||
|
|
BIN
docs/management/connectors/images/servicenow-itom-connector.png
Normal file
BIN
docs/management/connectors/images/servicenow-itom-connector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
After Width: | Height: | Size: 255 KiB |
|
@ -7,6 +7,7 @@ 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/servicenow-itom.asciidoc[]
|
||||
include::action-types/swimlane.asciidoc[]
|
||||
include::action-types/slack.asciidoc[]
|
||||
include::action-types/webhook.asciidoc[]
|
||||
|
|
|
@ -45,9 +45,12 @@ Table of Contents
|
|||
- [`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)
|
||||
- [ServiceNow ITOM](#servicenow-itom)
|
||||
- [`params`](#params-2)
|
||||
- [`subActionParams (addEvent)`](#subactionparams-addevent)
|
||||
- [`subActionParams (getChoices)`](#subactionparams-getchoices-2)
|
||||
- [Jira](#jira)
|
||||
- [`params`](#params-3)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
|
||||
- [`subActionParams (getIncident)`](#subactionparams-getincident-2)
|
||||
- [`subActionParams (issueTypes)`](#subactionparams-issuetypes)
|
||||
|
@ -56,13 +59,13 @@ Table of Contents
|
|||
- [`subActionParams (issue)`](#subactionparams-issue)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
|
||||
- [IBM Resilient](#ibm-resilient)
|
||||
- [`params`](#params-3)
|
||||
- [`params`](#params-4)
|
||||
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3)
|
||||
- [`subActionParams (getFields)`](#subactionparams-getfields-3)
|
||||
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
|
||||
- [`subActionParams (severity)`](#subactionparams-severity)
|
||||
- [Swimlane](#swimlane)
|
||||
- [`params`](#params-4)
|
||||
- [`params`](#params-5)
|
||||
- [| 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)
|
||||
|
@ -355,6 +358,43 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`.
|
|||
| Property | Description | Type |
|
||||
| -------- | ---------------------------------------------------- | -------- |
|
||||
| fields | An array of fields. Example: `[priority, category]`. | string[] |
|
||||
|
||||
---
|
||||
## ServiceNow ITOM
|
||||
|
||||
The [ServiceNow ITOM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-itom-action-type.html) lists configuration properties for the `addEvent` subaction. In addition, several other subaction types are available.
|
||||
### `params`
|
||||
|
||||
| Property | Description | Type |
|
||||
| --------------- | ----------------------------------------------------------------- | ------ |
|
||||
| subAction | The subaction to perform. It can be `addEvent`, and `getChoices`. | string |
|
||||
| subActionParams | The parameters of the subaction. | object |
|
||||
|
||||
#### `subActionParams (addEvent)`
|
||||
|
||||
|
||||
| Property | Description | Type |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| source | The name of the event source type. | string _(optional)_ |
|
||||
| event_class | Specific instance of the source. | string _(optional)_ |
|
||||
| resource | The name of the resource. | string _(optional)_ |
|
||||
| node | The Host that the event was triggered for. | string _(optional)_ |
|
||||
| metric_name | Name of the metric. | string _(optional)_ |
|
||||
| type | The type of event. | string _(optional)_ |
|
||||
| severity | The category in ServiceNow. | string _(optional)_ |
|
||||
| description | The subcategory in ServiceNow. | string _(optional)_ |
|
||||
| additional_info | Any additional information about the event. | string _(optional)_ |
|
||||
| message_key | This value is used for de-duplication of events. All actions sharing this key will be associated with the same ServiceNow alert. | string _(optional)_ |
|
||||
| time_of_event | The time of the event. | string _(optional)_ |
|
||||
|
||||
Refer to [ServiceNow documentation](https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html) for more information about the properties.
|
||||
|
||||
#### `subActionParams (getChoices)`
|
||||
|
||||
| Property | Description | Type |
|
||||
| -------- | ------------------------------------------ | -------- |
|
||||
| fields | An array of fields. Example: `[severity]`. | string[] |
|
||||
|
||||
---
|
||||
## Jira
|
||||
|
||||
|
@ -418,6 +458,7 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`.
|
|||
No parameters for the `getFields` subaction. Provide an empty object `{}`.
|
||||
|
||||
---
|
||||
|
||||
## IBM Resilient
|
||||
|
||||
The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/kibana/master/resilient-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
|
||||
|
@ -545,4 +586,4 @@ Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `sche
|
|||
|
||||
## user interface
|
||||
|
||||
To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui).
|
||||
To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui).
|
|
@ -16,10 +16,15 @@ import { getActionType as getSwimlaneActionType } from './swimlane';
|
|||
import { getActionType as getServerLogActionType } from './server_log';
|
||||
import { getActionType as getSlackActionType } from './slack';
|
||||
import { getActionType as getWebhookActionType } from './webhook';
|
||||
import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
|
||||
import {
|
||||
getServiceNowITSMActionType,
|
||||
getServiceNowSIRActionType,
|
||||
getServiceNowITOMActionType,
|
||||
} from './servicenow';
|
||||
import { getActionType as getJiraActionType } from './jira';
|
||||
import { getActionType as getResilientActionType } from './resilient';
|
||||
import { getActionType as getTeamsActionType } from './teams';
|
||||
import { ENABLE_ITOM } from '../constants/connectors';
|
||||
export { ActionParamsType as EmailActionParams, ActionTypeId as EmailActionTypeId } from './email';
|
||||
export {
|
||||
ActionParamsType as IndexActionParams,
|
||||
|
@ -42,6 +47,7 @@ export {
|
|||
ActionParamsType as ServiceNowActionParams,
|
||||
ServiceNowITSMActionTypeId,
|
||||
ServiceNowSIRActionTypeId,
|
||||
ServiceNowITOMActionTypeId,
|
||||
} from './servicenow';
|
||||
export { ActionParamsType as JiraActionParams, ActionTypeId as JiraActionTypeId } from './jira';
|
||||
export {
|
||||
|
@ -75,4 +81,9 @@ export function registerBuiltInActionTypes({
|
|||
actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities }));
|
||||
actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities }));
|
||||
actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities }));
|
||||
|
||||
// TODO: Remove when ITOM is ready
|
||||
if (ENABLE_ITOM) {
|
||||
actionTypeRegistry.register(getServiceNowITOMActionType({ logger, configurationUtilities }));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -361,6 +361,7 @@ describe('api', () => {
|
|||
const res = await api.getFields({
|
||||
externalService,
|
||||
params: {},
|
||||
logger: mockedLogger,
|
||||
});
|
||||
expect(res).toEqual(serviceNowCommonFields);
|
||||
});
|
||||
|
@ -371,6 +372,7 @@ describe('api', () => {
|
|||
const res = await api.getChoices({
|
||||
externalService,
|
||||
params: { fields: ['priority'] },
|
||||
logger: mockedLogger,
|
||||
});
|
||||
expect(res).toEqual(serviceNowChoices);
|
||||
});
|
||||
|
@ -383,6 +385,7 @@ describe('api', () => {
|
|||
params: {
|
||||
externalId: 'incident-1',
|
||||
},
|
||||
logger: mockedLogger,
|
||||
});
|
||||
expect(res).toEqual({
|
||||
description: 'description from servicenow',
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 { externalServiceITOMMock, itomEventParams } from './mocks';
|
||||
import { ExternalServiceITOM } from './types';
|
||||
import { apiITOM, prepareParams } from './api_itom';
|
||||
let mockedLogger: jest.Mocked<Logger>;
|
||||
|
||||
describe('api_itom', () => {
|
||||
let externalService: jest.Mocked<ExternalServiceITOM>;
|
||||
const eventParamsWithFormattedDate = {
|
||||
...itomEventParams,
|
||||
time_of_event: '2021-10-13, 10:51:44',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
externalService = externalServiceITOMMock.create();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('prepareParams', () => {
|
||||
test('it prepares the params correctly', async () => {
|
||||
expect(prepareParams(itomEventParams)).toEqual(eventParamsWithFormattedDate);
|
||||
});
|
||||
|
||||
test('it removes null values', async () => {
|
||||
const { time_of_event: timeOfEvent, ...rest } = itomEventParams;
|
||||
expect(prepareParams({ ...rest, time_of_event: null })).toEqual(rest);
|
||||
});
|
||||
|
||||
test('it set the time to null if it is not a proper date', async () => {
|
||||
const { time_of_event: timeOfEvent, ...rest } = itomEventParams;
|
||||
expect(prepareParams({ ...rest, time_of_event: 'not a proper date' })).toEqual(rest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEvent', () => {
|
||||
test('it adds an event correctly', async () => {
|
||||
await apiITOM.addEvent({
|
||||
externalService,
|
||||
params: itomEventParams,
|
||||
logger: mockedLogger,
|
||||
});
|
||||
|
||||
expect(externalService.addEvent).toHaveBeenCalledWith(eventParamsWithFormattedDate);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
import {
|
||||
ExecutorSubActionAddEventParams,
|
||||
AddEventApiHandlerArgs,
|
||||
ExternalServiceApiITOM,
|
||||
} from './types';
|
||||
|
||||
const isValidDate = (d: Date) => !isNaN(d.valueOf());
|
||||
|
||||
const formatTimeOfEvent = (timeOfEvent: string | null): string | undefined => {
|
||||
if (timeOfEvent != null) {
|
||||
const date = new Date(timeOfEvent);
|
||||
|
||||
return isValidDate(date)
|
||||
? // The format is: yyyy-MM-dd HH:mm:ss GMT
|
||||
date.toLocaleDateString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'GMT',
|
||||
})
|
||||
: undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeNullValues = (
|
||||
params: ExecutorSubActionAddEventParams
|
||||
): ExecutorSubActionAddEventParams =>
|
||||
(Object.keys(params) as Array<keyof ExecutorSubActionAddEventParams>).reduce(
|
||||
(acc, key) => ({
|
||||
...acc,
|
||||
...(params[key] != null ? { [key]: params[key] } : {}),
|
||||
}),
|
||||
{} as ExecutorSubActionAddEventParams
|
||||
);
|
||||
|
||||
export const prepareParams = (
|
||||
params: ExecutorSubActionAddEventParams
|
||||
): ExecutorSubActionAddEventParams => {
|
||||
const timeOfEvent = formatTimeOfEvent(params.time_of_event);
|
||||
return removeNullValues({
|
||||
...params,
|
||||
time_of_event: timeOfEvent ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const addEventServiceHandler = async ({
|
||||
externalService,
|
||||
params,
|
||||
}: AddEventApiHandlerArgs): Promise<void> => {
|
||||
const itomExternalService = externalService;
|
||||
const preparedParams = prepareParams(params);
|
||||
await itomExternalService.addEvent(preparedParams);
|
||||
};
|
||||
|
||||
export const apiITOM: ExternalServiceApiITOM = {
|
||||
getChoices: api.getChoices,
|
||||
addEvent: addEventServiceHandler,
|
||||
};
|
|
@ -37,4 +37,15 @@ describe('config', () => {
|
|||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
});
|
||||
|
||||
test('ITOM: the config are correct', async () => {
|
||||
const snConfig = snExternalServiceConfig['.servicenow-itom'];
|
||||
expect(snConfig).toEqual({
|
||||
importSetTable: 'x_elas2_inc_int_elastic_incident',
|
||||
appScope: 'x_elas2_inc_int',
|
||||
table: 'em_event',
|
||||
useImportAPI: true,
|
||||
commentFieldKey: 'work_notes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
ENABLE_ITOM,
|
||||
ENABLE_NEW_SN_ITSM_CONNECTOR,
|
||||
ENABLE_NEW_SN_SIR_CONNECTOR,
|
||||
} from '../../constants/connectors';
|
||||
|
@ -16,6 +17,7 @@ export const serviceNowSIRTable = 'sn_si_incident';
|
|||
|
||||
export const ServiceNowITSMActionTypeId = '.servicenow';
|
||||
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
|
||||
export const ServiceNowITOMActionTypeId = '.servicenow-itom';
|
||||
|
||||
export const snExternalServiceConfig: SNProductsConfig = {
|
||||
'.servicenow': {
|
||||
|
@ -32,6 +34,14 @@ export const snExternalServiceConfig: SNProductsConfig = {
|
|||
useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR,
|
||||
commentFieldKey: 'work_notes',
|
||||
},
|
||||
'.servicenow-itom': {
|
||||
importSetTable: 'x_elas2_inc_int_elastic_incident',
|
||||
appScope: 'x_elas2_inc_int',
|
||||
table: 'em_event',
|
||||
useImportAPI: ENABLE_ITOM,
|
||||
commentFieldKey: 'work_notes',
|
||||
},
|
||||
};
|
||||
|
||||
export const FIELD_PREFIX = 'u_';
|
||||
export const DEFAULT_ALERTS_GROUPING_KEY = '{{rule.id}}:{{alert.id}}';
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { actionsMock } from '../../mocks';
|
||||
import { createActionTypeRegistry } from '../index.test';
|
||||
import {
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowPublicConfigurationBaseType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
ExecutorParams,
|
||||
PushToServiceResponse,
|
||||
|
@ -56,7 +56,7 @@ describe('ServiceNow', () => {
|
|||
beforeAll(() => {
|
||||
const { actionTypeRegistry } = createActionTypeRegistry();
|
||||
actionType = actionTypeRegistry.get<
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowPublicConfigurationBaseType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
ExecutorParams,
|
||||
PushToServiceResponse | {}
|
||||
|
@ -91,7 +91,7 @@ describe('ServiceNow', () => {
|
|||
beforeAll(() => {
|
||||
const { actionTypeRegistry } = createActionTypeRegistry();
|
||||
actionType = actionTypeRegistry.get<
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowPublicConfigurationBaseType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
ExecutorParams,
|
||||
PushToServiceResponse | {}
|
||||
|
|
|
@ -11,9 +11,11 @@ import { schema, TypeOf } from '@kbn/config-schema';
|
|||
import { validate } from './validators';
|
||||
import {
|
||||
ExternalIncidentServiceConfiguration,
|
||||
ExternalIncidentServiceConfigurationBase,
|
||||
ExternalIncidentServiceSecretConfiguration,
|
||||
ExecutorParamsSchemaITSM,
|
||||
ExecutorParamsSchemaSIR,
|
||||
ExecutorParamsSchemaITOM,
|
||||
} from './schema';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
|
||||
|
@ -32,8 +34,14 @@ import {
|
|||
ExecutorSubActionGetChoicesParams,
|
||||
ServiceFactory,
|
||||
ExternalServiceAPI,
|
||||
ExecutorParamsITOM,
|
||||
ExecutorSubActionAddEventParams,
|
||||
ExternalServiceApiITOM,
|
||||
ExternalServiceITOM,
|
||||
ServiceNowPublicConfigurationBaseType,
|
||||
} from './types';
|
||||
import {
|
||||
ServiceNowITOMActionTypeId,
|
||||
ServiceNowITSMActionTypeId,
|
||||
serviceNowITSMTable,
|
||||
ServiceNowSIRActionTypeId,
|
||||
|
@ -42,12 +50,16 @@ import {
|
|||
} from './config';
|
||||
import { createExternalServiceSIR } from './service_sir';
|
||||
import { apiSIR } from './api_sir';
|
||||
import { throwIfSubActionIsNotSupported } from './utils';
|
||||
import { createExternalServiceITOM } from './service_itom';
|
||||
import { apiITOM } from './api_itom';
|
||||
|
||||
export {
|
||||
ServiceNowITSMActionTypeId,
|
||||
serviceNowITSMTable,
|
||||
ServiceNowSIRActionTypeId,
|
||||
serviceNowSIRTable,
|
||||
ServiceNowITOMActionTypeId,
|
||||
};
|
||||
|
||||
export type ActionParamsType =
|
||||
|
@ -59,21 +71,20 @@ interface GetActionTypeParams {
|
|||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
}
|
||||
|
||||
export type ServiceNowActionType = ActionType<
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
ExecutorParams,
|
||||
PushToServiceResponse | {}
|
||||
>;
|
||||
export type ServiceNowActionType<
|
||||
C extends Record<string, unknown> = ServiceNowPublicConfigurationBaseType,
|
||||
T extends Record<string, unknown> = ExecutorParams
|
||||
> = ActionType<C, ServiceNowSecretConfigurationType, T, PushToServiceResponse | {}>;
|
||||
|
||||
export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions<
|
||||
ServiceNowPublicConfigurationType,
|
||||
ServiceNowSecretConfigurationType,
|
||||
ExecutorParams
|
||||
>;
|
||||
export type ServiceNowActionTypeExecutorOptions<
|
||||
C extends Record<string, unknown> = ServiceNowPublicConfigurationBaseType,
|
||||
T extends Record<string, unknown> = ExecutorParams
|
||||
> = ActionTypeExecutorOptions<C, ServiceNowSecretConfigurationType, T>;
|
||||
|
||||
// action type definition
|
||||
export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType {
|
||||
export function getServiceNowITSMActionType(
|
||||
params: GetActionTypeParams
|
||||
): ServiceNowActionType<ServiceNowPublicConfigurationType, ExecutorParams> {
|
||||
const { logger, configurationUtilities } = params;
|
||||
return {
|
||||
id: ServiceNowITSMActionTypeId,
|
||||
|
@ -98,7 +109,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic
|
|||
};
|
||||
}
|
||||
|
||||
export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType {
|
||||
export function getServiceNowSIRActionType(
|
||||
params: GetActionTypeParams
|
||||
): ServiceNowActionType<ServiceNowPublicConfigurationType, ExecutorParams> {
|
||||
const { logger, configurationUtilities } = params;
|
||||
return {
|
||||
id: ServiceNowSIRActionTypeId,
|
||||
|
@ -123,6 +136,33 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service
|
|||
};
|
||||
}
|
||||
|
||||
export function getServiceNowITOMActionType(
|
||||
params: GetActionTypeParams
|
||||
): ServiceNowActionType<ServiceNowPublicConfigurationBaseType, ExecutorParamsITOM> {
|
||||
const { logger, configurationUtilities } = params;
|
||||
return {
|
||||
id: ServiceNowITOMActionTypeId,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
name: i18n.SERVICENOW_ITOM,
|
||||
validate: {
|
||||
config: schema.object(ExternalIncidentServiceConfigurationBase, {
|
||||
validate: curry(validate.config)(configurationUtilities),
|
||||
}),
|
||||
secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
|
||||
validate: curry(validate.secrets)(configurationUtilities),
|
||||
}),
|
||||
params: ExecutorParamsSchemaITOM,
|
||||
},
|
||||
executor: curry(executorITOM)({
|
||||
logger,
|
||||
configurationUtilities,
|
||||
actionTypeId: ServiceNowITOMActionTypeId,
|
||||
createService: createExternalServiceITOM,
|
||||
api: apiITOM,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// action executor
|
||||
const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident'];
|
||||
async function executor(
|
||||
|
@ -139,7 +179,10 @@ async function executor(
|
|||
createService: ServiceFactory;
|
||||
api: ExternalServiceAPI;
|
||||
},
|
||||
execOptions: ServiceNowActionTypeExecutorOptions
|
||||
execOptions: ServiceNowActionTypeExecutorOptions<
|
||||
ServiceNowPublicConfigurationType,
|
||||
ExecutorParams
|
||||
>
|
||||
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
|
||||
const { actionId, config, params, secrets } = execOptions;
|
||||
const { subAction, subActionParams } = params;
|
||||
|
@ -156,17 +199,8 @@ async function executor(
|
|||
externalServiceConfig
|
||||
);
|
||||
|
||||
if (!api[subAction]) {
|
||||
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (!supportedSubActions.includes(subAction)) {
|
||||
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const apiAsRecord = api as unknown as Record<string, unknown>;
|
||||
throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger });
|
||||
|
||||
if (subAction === 'pushToService') {
|
||||
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
|
||||
|
@ -187,6 +221,7 @@ async function executor(
|
|||
data = await api.getFields({
|
||||
externalService,
|
||||
params: getFieldsParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -195,6 +230,73 @@ async function executor(
|
|||
data = await api.getChoices({
|
||||
externalService,
|
||||
params: getChoicesParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
return { status: 'ok', data: data ?? {}, actionId };
|
||||
}
|
||||
|
||||
const supportedSubActionsITOM = ['addEvent', 'getChoices'];
|
||||
|
||||
async function executorITOM(
|
||||
{
|
||||
logger,
|
||||
configurationUtilities,
|
||||
actionTypeId,
|
||||
createService,
|
||||
api,
|
||||
}: {
|
||||
logger: Logger;
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
actionTypeId: string;
|
||||
createService: ServiceFactory<ExternalServiceITOM>;
|
||||
api: ExternalServiceApiITOM;
|
||||
},
|
||||
execOptions: ServiceNowActionTypeExecutorOptions<
|
||||
ServiceNowPublicConfigurationBaseType,
|
||||
ExecutorParamsITOM
|
||||
>
|
||||
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
|
||||
const { actionId, config, params, secrets } = execOptions;
|
||||
const { subAction, subActionParams } = params;
|
||||
const externalServiceConfig = snExternalServiceConfig[actionTypeId];
|
||||
let data: ServiceNowExecutorResultData | null = null;
|
||||
|
||||
const externalService = createService(
|
||||
{
|
||||
config,
|
||||
secrets,
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
externalServiceConfig
|
||||
) as ExternalServiceITOM;
|
||||
|
||||
const apiAsRecord = api as unknown as Record<string, unknown>;
|
||||
|
||||
throwIfSubActionIsNotSupported({
|
||||
api: apiAsRecord,
|
||||
subAction,
|
||||
supportedSubActions: supportedSubActionsITOM,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (subAction === 'addEvent') {
|
||||
const eventParams = subActionParams as ExecutorSubActionAddEventParams;
|
||||
await api.addEvent({
|
||||
externalService,
|
||||
params: eventParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
if (subAction === 'getChoices') {
|
||||
const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams;
|
||||
data = await api.getChoices({
|
||||
externalService,
|
||||
params: getChoicesParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@ import {
|
|||
ExternalServiceSIR,
|
||||
Observable,
|
||||
ObservableTypes,
|
||||
ExternalServiceITOM,
|
||||
ExecutorSubActionAddEventParams,
|
||||
} from './types';
|
||||
|
||||
export const serviceNowCommonFields = [
|
||||
|
@ -151,6 +153,16 @@ const createSIRMock = (): jest.Mocked<ExternalServiceSIR> => {
|
|||
return service;
|
||||
};
|
||||
|
||||
const createITOMMock = (): jest.Mocked<ExternalServiceITOM> => {
|
||||
const serviceMock = createMock();
|
||||
const service = {
|
||||
getChoices: serviceMock.getChoices,
|
||||
addEvent: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
return service;
|
||||
};
|
||||
|
||||
export const externalServiceMock = {
|
||||
create: createMock,
|
||||
};
|
||||
|
@ -159,6 +171,10 @@ export const externalServiceSIRMock = {
|
|||
create: createSIRMock,
|
||||
};
|
||||
|
||||
export const externalServiceITOMMock = {
|
||||
create: createITOMMock,
|
||||
};
|
||||
|
||||
export const executorParams: ExecutorSubActionPushParams = {
|
||||
incident: {
|
||||
externalId: 'incident-3',
|
||||
|
@ -227,3 +243,17 @@ export const observables: Observable[] = [
|
|||
];
|
||||
|
||||
export const apiParams = executorParams;
|
||||
|
||||
export const itomEventParams: ExecutorSubActionAddEventParams = {
|
||||
source: 'A source',
|
||||
event_class: 'An event class',
|
||||
resource: 'C:',
|
||||
node: 'node.example.com',
|
||||
metric_name: 'Percentage Logical Disk Free Space',
|
||||
type: 'Disk space',
|
||||
severity: '4',
|
||||
description: 'desc',
|
||||
additional_info: '{"alert": "test"}',
|
||||
message_key: 'a key',
|
||||
time_of_event: '2021-10-13T10:51:44.981Z',
|
||||
};
|
||||
|
|
|
@ -6,12 +6,21 @@
|
|||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { DEFAULT_ALERTS_GROUPING_KEY } from './config';
|
||||
|
||||
export const ExternalIncidentServiceConfigurationBase = {
|
||||
apiUrl: schema.string(),
|
||||
};
|
||||
|
||||
export const ExternalIncidentServiceConfiguration = {
|
||||
apiUrl: schema.string(),
|
||||
...ExternalIncidentServiceConfigurationBase,
|
||||
isLegacy: schema.boolean({ defaultValue: false }),
|
||||
};
|
||||
|
||||
export const ExternalIncidentServiceConfigurationBaseSchema = schema.object(
|
||||
ExternalIncidentServiceConfigurationBase
|
||||
);
|
||||
|
||||
export const ExternalIncidentServiceConfigurationSchema = schema.object(
|
||||
ExternalIncidentServiceConfiguration
|
||||
);
|
||||
|
@ -80,6 +89,21 @@ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
|
|||
comments: CommentsSchema,
|
||||
});
|
||||
|
||||
// Schema for ServiceNow ITOM
|
||||
export const ExecutorSubActionAddEventParamsSchema = schema.object({
|
||||
source: schema.nullable(schema.string()),
|
||||
event_class: schema.nullable(schema.string()),
|
||||
resource: schema.nullable(schema.string()),
|
||||
node: schema.nullable(schema.string()),
|
||||
metric_name: schema.nullable(schema.string()),
|
||||
type: schema.nullable(schema.string()),
|
||||
severity: schema.nullable(schema.string()),
|
||||
description: schema.nullable(schema.string()),
|
||||
additional_info: schema.nullable(schema.string()),
|
||||
message_key: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })),
|
||||
time_of_event: schema.nullable(schema.string()),
|
||||
});
|
||||
|
||||
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
|
||||
externalId: schema.string(),
|
||||
});
|
||||
|
@ -138,3 +162,15 @@ export const ExecutorParamsSchemaSIR = schema.oneOf([
|
|||
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Executor parameters for ITOM
|
||||
export const ExecutorParamsSchemaITOM = schema.oneOf([
|
||||
schema.object({
|
||||
subAction: schema.literal('addEvent'),
|
||||
subActionParams: ExecutorSubActionAddEventParamsSchema,
|
||||
}),
|
||||
schema.object({
|
||||
subAction: schema.literal('getChoices'),
|
||||
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
|
||||
}),
|
||||
]);
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import { createExternalServiceITOM } from './service_itom';
|
||||
import * as utils from '../lib/axios_utils';
|
||||
import { ExternalServiceITOM } from './types';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
import { snExternalServiceConfig } from './config';
|
||||
import { itomEventParams, serviceNowChoices } from './mocks';
|
||||
|
||||
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(),
|
||||
};
|
||||
});
|
||||
|
||||
axios.create = jest.fn(() => axios);
|
||||
const requestMock = utils.request as jest.Mock;
|
||||
const configurationUtilities = actionsConfigMock.create();
|
||||
|
||||
describe('ServiceNow SIR service', () => {
|
||||
let service: ExternalServiceITOM;
|
||||
|
||||
beforeEach(() => {
|
||||
service = createExternalServiceITOM(
|
||||
{
|
||||
config: { apiUrl: 'https://example.com/' },
|
||||
secrets: { username: 'admin', password: 'admin' },
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
snExternalServiceConfig['.servicenow-itom']
|
||||
) as ExternalServiceITOM;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('addEvent', () => {
|
||||
test('it adds an event', async () => {
|
||||
requestMock.mockImplementationOnce(() => ({
|
||||
data: {
|
||||
result: {
|
||||
'Default Bulk Endpoint': '1 events were inserted',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await service.addEvent(itomEventParams);
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/global/em/jsonv2',
|
||||
method: 'post',
|
||||
data: { records: [itomEventParams] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChoices', () => {
|
||||
test('it should call request with correct arguments', async () => {
|
||||
requestMock.mockImplementation(() => ({
|
||||
data: { result: serviceNowChoices },
|
||||
}));
|
||||
await service.getChoices(['severity']);
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith({
|
||||
axios,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=em_event^element=severity&sysparm_fields=label,value,dependent_value,element',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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,
|
||||
ServiceFactory,
|
||||
ExternalServiceITOM,
|
||||
ExecutorSubActionAddEventParams,
|
||||
} 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 getAddEventURL = (url: string) => `${url}/api/global/em/jsonv2`;
|
||||
|
||||
export const createExternalServiceITOM: ServiceFactory<ExternalServiceITOM> = (
|
||||
credentials: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
serviceConfig: SNProductsConfigValue
|
||||
): ExternalServiceITOM => {
|
||||
const snService = createExternalService(
|
||||
credentials,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
serviceConfig
|
||||
);
|
||||
|
||||
const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType;
|
||||
const axiosInstance = axios.create({
|
||||
auth: { username, password },
|
||||
});
|
||||
|
||||
const addEvent = async (params: ExecutorSubActionAddEventParams) => {
|
||||
try {
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
url: getAddEventURL(snService.getUrl()),
|
||||
logger,
|
||||
method: 'post',
|
||||
data: { records: [params] },
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
snService.checkInstance(res);
|
||||
} catch (error) {
|
||||
throw createServiceError(error, `Unable to add event`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
addEvent,
|
||||
getChoices: snService.getChoices,
|
||||
};
|
||||
};
|
|
@ -29,7 +29,7 @@ const getAddObservableToIncidentURL = (url: string, incidentID: string) =>
|
|||
const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) =>
|
||||
`${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`;
|
||||
|
||||
export const createExternalServiceSIR: ServiceFactory = (
|
||||
export const createExternalServiceSIR: ServiceFactory<ExternalServiceSIR> = (
|
||||
credentials: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
|
|
|
@ -19,6 +19,10 @@ export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSI
|
|||
defaultMessage: 'ServiceNow SecOps',
|
||||
});
|
||||
|
||||
export const SERVICENOW_ITOM = i18n.translate('xpack.actions.builtin.serviceNowITOMTitle', {
|
||||
defaultMessage: 'ServiceNow ITOM',
|
||||
});
|
||||
|
||||
export const ALLOWED_HOSTS_ERROR = (message: string) =>
|
||||
i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', {
|
||||
defaultMessage: 'error configuring connector action: {message}',
|
||||
|
|
|
@ -20,13 +20,21 @@ import {
|
|||
ExecutorParamsSchemaSIR,
|
||||
ExecutorSubActionPushParamsSchemaSIR,
|
||||
ExecutorSubActionGetChoicesParamsSchema,
|
||||
ExecutorParamsSchemaITOM,
|
||||
ExecutorSubActionAddEventParamsSchema,
|
||||
ExternalIncidentServiceConfigurationBaseSchema,
|
||||
} from './schema';
|
||||
import { ActionsConfigurationUtilities } from '../../actions_config';
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
|
||||
export type ServiceNowPublicConfigurationBaseType = TypeOf<
|
||||
typeof ExternalIncidentServiceConfigurationBaseSchema
|
||||
>;
|
||||
|
||||
export type ServiceNowPublicConfigurationType = TypeOf<
|
||||
typeof ExternalIncidentServiceConfigurationSchema
|
||||
>;
|
||||
|
||||
export type ServiceNowSecretConfigurationType = TypeOf<
|
||||
typeof ExternalIncidentServiceSecretConfigurationSchema
|
||||
>;
|
||||
|
@ -108,8 +116,9 @@ export type PushToServiceApiParams = ExecutorSubActionPushParams;
|
|||
export type PushToServiceApiParamsITSM = ExecutorSubActionPushParamsITSM;
|
||||
export type PushToServiceApiParamsSIR = ExecutorSubActionPushParamsSIR;
|
||||
|
||||
export interface ExternalServiceApiHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
export interface ExternalServiceApiHandlerArgs<T = ExternalService> {
|
||||
externalService: T;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export type ExecutorSubActionGetIncidentParams = TypeOf<
|
||||
|
@ -134,7 +143,6 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr
|
|||
params: PushToServiceApiParams;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, unknown>;
|
||||
logger: Logger;
|
||||
commentFieldKey: string;
|
||||
}
|
||||
|
||||
|
@ -162,13 +170,13 @@ export interface ExternalServiceChoices {
|
|||
export type GetCommonFieldsResponse = ExternalServiceFields[];
|
||||
export type GetChoicesResponse = ExternalServiceChoices[];
|
||||
|
||||
export interface GetCommonFieldsHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
export interface GetCommonFieldsHandlerArgs extends ExternalServiceApiHandlerArgs {
|
||||
params: ExecutorSubActionCommonFieldsParams;
|
||||
}
|
||||
|
||||
export interface GetChoicesHandlerArgs {
|
||||
externalService: ExternalService;
|
||||
externalService: Partial<ExternalService> & { getChoices: ExternalService['getChoices'] };
|
||||
logger: Logger;
|
||||
params: ExecutorSubActionGetChoicesParams;
|
||||
}
|
||||
|
||||
|
@ -276,9 +284,36 @@ export interface ExternalServiceSIR extends ExternalService {
|
|||
) => Promise<ObservableResponse[]>;
|
||||
}
|
||||
|
||||
export type ServiceFactory = (
|
||||
export type ServiceFactory<T = ExternalService> = (
|
||||
credentials: ExternalServiceCredentials,
|
||||
logger: Logger,
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
serviceConfig: SNProductsConfigValue
|
||||
) => ExternalServiceSIR | ExternalService;
|
||||
) => T;
|
||||
|
||||
/**
|
||||
* ITOM
|
||||
*/
|
||||
|
||||
export type ExecutorSubActionAddEventParams = TypeOf<typeof ExecutorSubActionAddEventParamsSchema>;
|
||||
|
||||
export interface ExternalServiceITOM {
|
||||
getChoices: ExternalService['getChoices'];
|
||||
addEvent: (params: ExecutorSubActionAddEventParams) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AddEventApiHandlerArgs extends ExternalServiceApiHandlerArgs<ExternalServiceITOM> {
|
||||
params: ExecutorSubActionAddEventParams;
|
||||
}
|
||||
|
||||
export interface GetCommonFieldsHandlerArgsITOM
|
||||
extends ExternalServiceApiHandlerArgs<ExternalServiceITOM> {
|
||||
params: ExecutorSubActionGetChoicesParams;
|
||||
}
|
||||
|
||||
export interface ExternalServiceApiITOM {
|
||||
getChoices: ExternalServiceAPI['getChoices'];
|
||||
addEvent: (args: AddEventApiHandlerArgs) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ExecutorParamsITOM = TypeOf<typeof ExecutorParamsSchemaITOM>;
|
||||
|
|
|
@ -6,7 +6,17 @@
|
|||
*/
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import { prepareIncident, createServiceError, getPushedDate } from './utils';
|
||||
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
|
||||
import {
|
||||
prepareIncident,
|
||||
createServiceError,
|
||||
getPushedDate,
|
||||
throwIfSubActionIsNotSupported,
|
||||
} from './utils';
|
||||
|
||||
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
||||
|
||||
/**
|
||||
* The purpose of this test is to
|
||||
|
@ -15,7 +25,6 @@ import { prepareIncident, createServiceError, getPushedDate } from './utils';
|
|||
* 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 () => {
|
||||
|
@ -81,4 +90,45 @@ describe('utils', () => {
|
|||
expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwIfSubActionIsNotSupported', () => {
|
||||
const api = { pushToService: 'whatever' };
|
||||
|
||||
test('it throws correctly if the subAction is not supported', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
expect(() =>
|
||||
throwIfSubActionIsNotSupported({
|
||||
api,
|
||||
subAction: 'addEvent',
|
||||
supportedSubActions: ['getChoices'],
|
||||
logger,
|
||||
})
|
||||
).toThrow('[Action][ExternalService] Unsupported subAction type addEvent');
|
||||
});
|
||||
|
||||
test('it throws correctly if the subAction is not implemented', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
expect(() =>
|
||||
throwIfSubActionIsNotSupported({
|
||||
api,
|
||||
subAction: 'pushToService',
|
||||
supportedSubActions: ['getChoices'],
|
||||
logger,
|
||||
})
|
||||
).toThrow('[Action][ExternalService] subAction pushToService not implemented.');
|
||||
});
|
||||
|
||||
test('it does not throw if the sub action is supported and implemented', async () => {
|
||||
expect(() =>
|
||||
throwIfSubActionIsNotSupported({
|
||||
api,
|
||||
subAction: 'pushToService',
|
||||
supportedSubActions: ['pushToService'],
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Logger } from '../../../../../../src/core/server';
|
||||
import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types';
|
||||
import { FIELD_PREFIX } from './config';
|
||||
import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils';
|
||||
|
@ -44,3 +45,27 @@ export const getPushedDate = (timestamp?: string) => {
|
|||
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
export const throwIfSubActionIsNotSupported = ({
|
||||
api,
|
||||
subAction,
|
||||
supportedSubActions,
|
||||
logger,
|
||||
}: {
|
||||
api: Record<string, unknown>;
|
||||
subAction: string;
|
||||
supportedSubActions: string[];
|
||||
logger: Logger;
|
||||
}) => {
|
||||
if (!api[subAction]) {
|
||||
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (!supportedSubActions.includes(subAction)) {
|
||||
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -10,3 +10,6 @@ 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;
|
||||
|
||||
// TODO: Remove when ready
|
||||
export const ENABLE_ITOM = true;
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { ENABLE_ITOM } from '../../actions/server/constants/connectors';
|
||||
import type { TransformConfigSchema } from './transforms/types';
|
||||
import { ENABLE_CASE_CONNECTOR } from '../../cases/common';
|
||||
import { METADATA_TRANSFORMS_PATTERN } from './endpoint/constants';
|
||||
|
@ -312,6 +314,11 @@ if (ENABLE_CASE_CONNECTOR) {
|
|||
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.case');
|
||||
}
|
||||
|
||||
// TODO: Remove when ITOM is ready
|
||||
if (ENABLE_ITOM) {
|
||||
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.servicenow-itom');
|
||||
}
|
||||
|
||||
export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions';
|
||||
export const NOTIFICATION_THROTTLE_RULE = 'rule';
|
||||
|
||||
|
|
|
@ -14,10 +14,16 @@ import { getSwimlaneActionType } from './swimlane';
|
|||
import { getWebhookActionType } from './webhook';
|
||||
import { TypeRegistry } from '../../type_registry';
|
||||
import { ActionTypeModel } from '../../../types';
|
||||
import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
|
||||
import {
|
||||
getServiceNowITSMActionType,
|
||||
getServiceNowSIRActionType,
|
||||
getServiceNowITOMActionType,
|
||||
} from './servicenow';
|
||||
import { getJiraActionType } from './jira';
|
||||
import { getResilientActionType } from './resilient';
|
||||
import { getTeamsActionType } from './teams';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { ENABLE_ITOM } from '../../../../../actions/server/constants/connectors';
|
||||
|
||||
export function registerBuiltInActionTypes({
|
||||
actionTypeRegistry,
|
||||
|
@ -36,4 +42,9 @@ export function registerBuiltInActionTypes({
|
|||
actionTypeRegistry.register(getJiraActionType());
|
||||
actionTypeRegistry.register(getResilientActionType());
|
||||
actionTypeRegistry.register(getTeamsActionType());
|
||||
|
||||
// TODO: Remove when ITOM is ready
|
||||
if (ENABLE_ITOM) {
|
||||
actionTypeRegistry.register(getServiceNowITOMActionType());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,10 @@ describe('helpers', () => {
|
|||
expect(isFieldInvalid(undefined, ['required'])).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should return if false the field is null', async () => {
|
||||
expect(isFieldInvalid(null, ['required'])).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should return if false the error is not defined', async () => {
|
||||
// @ts-expect-error
|
||||
expect(isFieldInvalid('description', undefined)).toBeFalsy();
|
||||
|
|
|
@ -23,9 +23,9 @@ 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,
|
||||
field: string | undefined | null,
|
||||
error: string | IErrorObject | string[]
|
||||
): boolean => error !== undefined && error.length > 0 && field !== undefined;
|
||||
): boolean => error !== undefined && error.length > 0 && field != null;
|
||||
|
||||
// TODO: Remove when the applications are certified
|
||||
export const isLegacyConnector = (connector: ServiceNowActionConnector) => {
|
||||
|
|
|
@ -5,4 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
|
||||
export {
|
||||
getServiceNowITSMActionType,
|
||||
getServiceNowSIRActionType,
|
||||
getServiceNowITOMActionType,
|
||||
} from './servicenow';
|
||||
|
|
|
@ -12,6 +12,7 @@ import { ServiceNowActionConnector } from './types';
|
|||
|
||||
const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
|
||||
const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
|
||||
const SERVICENOW_ITOM_ACTION_TYPE_ID = '.servicenow-itom';
|
||||
let actionTypeRegistry: TypeRegistry<ActionTypeModel>;
|
||||
|
||||
beforeAll(() => {
|
||||
|
@ -20,7 +21,11 @@ beforeAll(() => {
|
|||
});
|
||||
|
||||
describe('actionTypeRegistry.get() works', () => {
|
||||
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
|
||||
[
|
||||
SERVICENOW_ITSM_ACTION_TYPE_ID,
|
||||
SERVICENOW_SIR_ACTION_TYPE_ID,
|
||||
SERVICENOW_ITOM_ACTION_TYPE_ID,
|
||||
].forEach((id) => {
|
||||
test(`${id}: action type static data is as expected`, () => {
|
||||
const actionTypeModel = actionTypeRegistry.get(id);
|
||||
expect(actionTypeModel.id).toEqual(id);
|
||||
|
@ -29,7 +34,11 @@ describe('actionTypeRegistry.get() works', () => {
|
|||
});
|
||||
|
||||
describe('servicenow connector validation', () => {
|
||||
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
|
||||
[
|
||||
SERVICENOW_ITSM_ACTION_TYPE_ID,
|
||||
SERVICENOW_SIR_ACTION_TYPE_ID,
|
||||
SERVICENOW_ITOM_ACTION_TYPE_ID,
|
||||
].forEach((id) => {
|
||||
test(`${id}: connector validation succeeds when connector config is valid`, async () => {
|
||||
const actionTypeModel = actionTypeRegistry.get(id);
|
||||
const actionConnector = {
|
||||
|
@ -106,7 +115,7 @@ describe('servicenow action params validation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test(`${id}: params validation fails when body is not valid`, async () => {
|
||||
test(`${id}: params validation fails when short_description is not valid`, async () => {
|
||||
const actionTypeModel = actionTypeRegistry.get(id);
|
||||
const actionParams = {
|
||||
subActionParams: { incident: { short_description: '' }, comments: [] },
|
||||
|
@ -119,4 +128,22 @@ describe('servicenow action params validation', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: action params validation succeeds when action params is valid`, async () => {
|
||||
const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID);
|
||||
const actionParams = { subActionParams: { severity: 'Critical' } };
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: { ['severity']: [] },
|
||||
});
|
||||
});
|
||||
|
||||
test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: params validation fails when severity is not valid`, async () => {
|
||||
const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID);
|
||||
const actionParams = { subActionParams: { severity: null } };
|
||||
|
||||
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: { ['severity']: ['Severity is required.'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
ServiceNowSecrets,
|
||||
ServiceNowITSMActionParams,
|
||||
ServiceNowSIRActionParams,
|
||||
ServiceNowITOMActionParams,
|
||||
} from './types';
|
||||
import { isValidUrl } from '../../../lib/value_validators';
|
||||
|
||||
|
@ -90,6 +91,20 @@ export const SERVICENOW_SIR_TITLE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const SERVICENOW_ITOM_TITLE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.actionTypeTitle',
|
||||
{
|
||||
defaultMessage: 'ServiceNow ITOM',
|
||||
}
|
||||
);
|
||||
|
||||
export const SERVICENOW_ITOM_DESC = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.selectMessageText',
|
||||
{
|
||||
defaultMessage: 'Create an event in ServiceNow ITOM.',
|
||||
}
|
||||
);
|
||||
|
||||
export function getServiceNowITSMActionType(): ActionTypeModel<
|
||||
ServiceNowConfig,
|
||||
ServiceNowSecrets,
|
||||
|
@ -161,3 +176,34 @@ export function getServiceNowSIRActionType(): ActionTypeModel<
|
|||
actionParamsFields: lazy(() => import('./servicenow_sir_params')),
|
||||
};
|
||||
}
|
||||
|
||||
export function getServiceNowITOMActionType(): ActionTypeModel<
|
||||
ServiceNowConfig,
|
||||
ServiceNowSecrets,
|
||||
ServiceNowITOMActionParams
|
||||
> {
|
||||
return {
|
||||
id: '.servicenow-itom',
|
||||
iconClass: lazy(() => import('./logo')),
|
||||
selectMessage: SERVICENOW_ITOM_DESC,
|
||||
actionTypeTitle: SERVICENOW_ITOM_TITLE,
|
||||
validateConnector,
|
||||
actionConnectorFields: lazy(() => import('./servicenow_connectors_no_app')),
|
||||
validateParams: async (
|
||||
actionParams: ServiceNowITOMActionParams
|
||||
): Promise<GenericValidationResult<unknown>> => {
|
||||
const translations = await import('./translations');
|
||||
const errors = {
|
||||
severity: new Array<string>(),
|
||||
};
|
||||
const validationResult = { errors };
|
||||
|
||||
if (actionParams?.subActionParams?.severity == null) {
|
||||
errors.severity.push(translations.SEVERITY_REQUIRED);
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
},
|
||||
actionParamsFields: lazy(() => import('./servicenow_itom_params')),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { ActionConnectorFieldsProps } from '../../../../types';
|
||||
|
||||
import { ServiceNowActionConnector } from './types';
|
||||
import { Credentials } from './credentials';
|
||||
|
||||
const ServiceNowConnectorFieldsNoApp: React.FC<
|
||||
ActionConnectorFieldsProps<ServiceNowActionConnector>
|
||||
> = ({ action, editActionSecrets, editActionConfig, errors, readOnly }) => {
|
||||
return (
|
||||
<>
|
||||
<Credentials
|
||||
action={action}
|
||||
errors={errors}
|
||||
readOnly={readOnly}
|
||||
isLoading={false}
|
||||
editActionSecrets={editActionSecrets}
|
||||
editActionConfig={editActionConfig}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { ServiceNowConnectorFieldsNoApp as default };
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { ActionConnector } from '../../../../types';
|
||||
import { useChoices } from './use_choices';
|
||||
import ServiceNowITOMParamsFields from './servicenow_itom_params';
|
||||
|
||||
jest.mock('./use_choices');
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const useChoicesMock = useChoices as jest.Mock;
|
||||
|
||||
const actionParams = {
|
||||
subAction: 'addEvent',
|
||||
subActionParams: {
|
||||
source: 'A source',
|
||||
event_class: 'An event class',
|
||||
resource: 'C:',
|
||||
node: 'node.example.com',
|
||||
metric_name: 'Percentage Logical Disk Free Space',
|
||||
type: 'Disk space',
|
||||
severity: '4',
|
||||
description: 'desc',
|
||||
additional_info: '{"alert": "test"}',
|
||||
message_key: 'a key',
|
||||
time_of_event: '2021-10-13T10:51:44.981Z',
|
||||
},
|
||||
};
|
||||
|
||||
const connector: ActionConnector = {
|
||||
secrets: {},
|
||||
config: {},
|
||||
id: 'test',
|
||||
actionTypeId: '.test',
|
||||
name: 'Test',
|
||||
isPreconfigured: false,
|
||||
};
|
||||
|
||||
const editAction = jest.fn();
|
||||
const defaultProps = {
|
||||
actionConnector: connector,
|
||||
actionParams,
|
||||
errors: { ['subActionParams.incident.short_description']: [] },
|
||||
editAction,
|
||||
index: 0,
|
||||
messageVariables: [],
|
||||
};
|
||||
|
||||
const choicesResponse = {
|
||||
isLoading: false,
|
||||
choices: {
|
||||
severity: [
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '1 - Critical',
|
||||
value: '1',
|
||||
element: 'severity',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '2 - Major',
|
||||
value: '2',
|
||||
element: 'severity',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ServiceNowITOMParamsFields renders', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useChoicesMock.mockImplementation((args) => {
|
||||
return choicesResponse;
|
||||
});
|
||||
});
|
||||
|
||||
test('all params fields is rendered', () => {
|
||||
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
|
||||
expect(wrapper.find('[data-test-subj="sourceInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="nodeInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="typeInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="resourceInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="metric_nameInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="event_classInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="message_keyInput"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('If severity has errors, form row is invalid', () => {
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
errors: { severity: ['error'] },
|
||||
};
|
||||
const wrapper = mount(<ServiceNowITOMParamsFields {...newProps} />);
|
||||
const severity = wrapper.find('[data-test-subj="severitySelect"]').first();
|
||||
expect(severity.prop('isInvalid')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('When subActionParams is undefined, set to default', () => {
|
||||
const { subActionParams, ...newParams } = actionParams;
|
||||
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
actionParams: newParams,
|
||||
};
|
||||
|
||||
mount(<ServiceNowITOMParamsFields {...newProps} />);
|
||||
expect(editAction.mock.calls[0][1]).toEqual({
|
||||
message_key: '{{rule.id}}:{{alert.id}}',
|
||||
additional_info:
|
||||
'{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}',
|
||||
});
|
||||
});
|
||||
|
||||
test('When subAction is undefined, set to default', () => {
|
||||
const { subAction, ...newParams } = actionParams;
|
||||
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
actionParams: newParams,
|
||||
};
|
||||
mount(<ServiceNowITOMParamsFields {...newProps} />);
|
||||
expect(editAction.mock.calls[0][1]).toEqual('addEvent');
|
||||
});
|
||||
|
||||
test('Resets fields when connector changes', () => {
|
||||
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
|
||||
expect(editAction.mock.calls.length).toEqual(0);
|
||||
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
|
||||
expect(editAction.mock.calls.length).toEqual(1);
|
||||
expect(editAction.mock.calls[0][1]).toEqual({
|
||||
message_key: '{{rule.id}}:{{alert.id}}',
|
||||
additional_info:
|
||||
'{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}',
|
||||
});
|
||||
});
|
||||
|
||||
test('it transforms the categories to options correctly', async () => {
|
||||
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
|
||||
|
||||
wrapper.update();
|
||||
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([
|
||||
{ value: '1', text: '1 - Critical' },
|
||||
{ value: '2', text: '2 - Major' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('UI updates', () => {
|
||||
const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent<HTMLSelectElement>;
|
||||
const simpleFields = [
|
||||
{ dataTestSubj: 'input[data-test-subj="sourceInput"]', key: 'source' },
|
||||
{ dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' },
|
||||
{ dataTestSubj: '[data-test-subj="nodeInput"]', key: 'node' },
|
||||
{ dataTestSubj: '[data-test-subj="typeInput"]', key: 'type' },
|
||||
{ dataTestSubj: '[data-test-subj="resourceInput"]', key: 'resource' },
|
||||
{ dataTestSubj: '[data-test-subj="metric_nameInput"]', key: 'metric_name' },
|
||||
{ dataTestSubj: '[data-test-subj="event_classInput"]', key: 'event_class' },
|
||||
{ dataTestSubj: '[data-test-subj="message_keyInput"]', key: 'message_key' },
|
||||
{ dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' },
|
||||
];
|
||||
|
||||
simpleFields.forEach((field) =>
|
||||
test(`${field.key} update triggers editAction :D`, () => {
|
||||
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
|
||||
const theField = wrapper.find(field.dataTestSubj).first();
|
||||
theField.prop('onChange')!(changeEvent);
|
||||
expect(editAction.mock.calls[0][1][field.key]).toEqual(changeEvent.target.value);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { EuiFormRow, EuiSpacer, EuiTitle, EuiSelect } from '@elastic/eui';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { ActionParamsProps } from '../../../../types';
|
||||
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
|
||||
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { useChoices } from './use_choices';
|
||||
import { ServiceNowITOMActionParams } from './types';
|
||||
import { choicesToEuiOptions, isFieldInvalid } from './helpers';
|
||||
|
||||
const choicesFields = ['severity'];
|
||||
|
||||
const fields: Array<{
|
||||
label: string;
|
||||
fieldKey: keyof ServiceNowITOMActionParams['subActionParams'];
|
||||
}> = [
|
||||
{ label: i18n.SOURCE, fieldKey: 'source' },
|
||||
{ label: i18n.NODE, fieldKey: 'node' },
|
||||
{ label: i18n.TYPE, fieldKey: 'type' },
|
||||
{ label: i18n.RESOURCE, fieldKey: 'resource' },
|
||||
{ label: i18n.METRIC_NAME, fieldKey: 'metric_name' },
|
||||
{ label: i18n.EVENT_CLASS, fieldKey: 'event_class' },
|
||||
{ label: i18n.MESSAGE_KEY, fieldKey: 'message_key' },
|
||||
];
|
||||
|
||||
const additionalInformation = JSON.stringify({
|
||||
alert: {
|
||||
id: '{{alert.id}}',
|
||||
actionGroup: '{{alert.actionGroup}}',
|
||||
actionSubgroup: '{{alert.actionSubgroup}}',
|
||||
actionGroupName: '{{alert.actionGroupName}}',
|
||||
},
|
||||
rule: {
|
||||
id: '{{rule.id}}',
|
||||
name: '{{rule.name}}',
|
||||
type: '{{rule.type}}',
|
||||
},
|
||||
date: '{{date}}',
|
||||
});
|
||||
|
||||
const ServiceNowITOMParamsFields: React.FunctionComponent<
|
||||
ActionParamsProps<ServiceNowITOMActionParams>
|
||||
> = ({ actionConnector, actionParams, editAction, index, messageVariables, errors }) => {
|
||||
const params = useMemo(
|
||||
() => (actionParams.subActionParams ?? {}) as ServiceNowITOMActionParams['subActionParams'],
|
||||
[actionParams.subActionParams]
|
||||
);
|
||||
|
||||
const { description, severity } = params;
|
||||
|
||||
const {
|
||||
http,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const actionConnectorRef = useRef(actionConnector?.id ?? '');
|
||||
const { choices, isLoading: isLoadingChoices } = useChoices({
|
||||
http,
|
||||
toastNotifications: toasts,
|
||||
actionConnector,
|
||||
fields: choicesFields,
|
||||
});
|
||||
|
||||
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
|
||||
|
||||
const editSubActionProperty = useCallback(
|
||||
(key: string, value: any) => {
|
||||
editAction('subActionParams', { ...params, [key]: value }, index);
|
||||
},
|
||||
[editAction, index, params]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
|
||||
actionConnectorRef.current = actionConnector.id;
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{ additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' },
|
||||
index
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionConnector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionParams.subAction) {
|
||||
editAction('subAction', 'addEvent', index);
|
||||
}
|
||||
|
||||
if (!actionParams.subActionParams) {
|
||||
editAction(
|
||||
'subActionParams',
|
||||
{ additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' },
|
||||
index
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTitle size="s">
|
||||
<h3>{i18n.EVENT}</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
{fields.map((field) => (
|
||||
<React.Fragment key={field.fieldKey}>
|
||||
<EuiFormRow fullWidth label={field.label}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={field.fieldKey}
|
||||
inputTargetValue={params[field.fieldKey] ?? undefined}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.SEVERITY_REQUIRED_LABEL}
|
||||
error={errors.severity}
|
||||
isInvalid={isFieldInvalid(severity, errors.severity)}
|
||||
>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
hasNoInitialSelection
|
||||
data-test-subj="severitySelect"
|
||||
isLoading={isLoadingChoices}
|
||||
disabled={isLoadingChoices}
|
||||
options={severityOptions}
|
||||
value={severity ?? ''}
|
||||
onChange={(e) => editSubActionProperty('severity', e.target.value)}
|
||||
isInvalid={isFieldInvalid(severity, errors.severity)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubActionProperty}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'description'}
|
||||
inputTargetValue={description ?? undefined}
|
||||
label={i18n.DESCRIPTION_LABEL}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { ServiceNowITOMParamsFields as default };
|
|
@ -152,7 +152,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
|
|||
return (
|
||||
<>
|
||||
<EuiTitle size="s">
|
||||
<h3>{i18n.INCIDENT}</h3>
|
||||
<h3>{i18n.SECURITY_INCIDENT}</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
|
|
|
@ -106,6 +106,13 @@ export const INCIDENT = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const SECURITY_INCIDENT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenowSIR.title',
|
||||
{
|
||||
defaultMessage: 'Security Incident',
|
||||
}
|
||||
);
|
||||
|
||||
export const SHORT_DESCRIPTION_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel',
|
||||
{
|
||||
|
@ -256,3 +263,76 @@ export const CORRELATION_DISPLAY = i18n.translate(
|
|||
defaultMessage: 'Correlation display (optional)',
|
||||
}
|
||||
);
|
||||
|
||||
export const EVENT = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenowITOM.event',
|
||||
{
|
||||
defaultMessage: 'Event',
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* ITOM
|
||||
*/
|
||||
export const SOURCE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Source',
|
||||
}
|
||||
);
|
||||
|
||||
export const EVENT_CLASS = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.eventClassTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Source instance',
|
||||
}
|
||||
);
|
||||
|
||||
export const RESOURCE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.resourceTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Resource',
|
||||
}
|
||||
);
|
||||
|
||||
export const NODE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.nodeTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Node',
|
||||
}
|
||||
);
|
||||
|
||||
export const METRIC_NAME = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.metricNameTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Metric name',
|
||||
}
|
||||
);
|
||||
|
||||
export const TYPE = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.typeTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Type',
|
||||
}
|
||||
);
|
||||
|
||||
export const MESSAGE_KEY = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.messageKeyTextAreaFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Message key',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_REQUIRED = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredSeverityTextField',
|
||||
{
|
||||
defaultMessage: 'Severity is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_REQUIRED_LABEL = i18n.translate(
|
||||
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severityRequiredSelectFieldLabel',
|
||||
{
|
||||
defaultMessage: 'Severity (required)',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -9,6 +9,7 @@ import { UserConfiguredActionConnector } from '../../../../types';
|
|||
import {
|
||||
ExecutorSubActionPushParamsITSM,
|
||||
ExecutorSubActionPushParamsSIR,
|
||||
ExecutorSubActionAddEventParams,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from '../../../../../../actions/server/builtin_action_types/servicenow/types';
|
||||
|
||||
|
@ -27,6 +28,11 @@ export interface ServiceNowSIRActionParams {
|
|||
subActionParams: ExecutorSubActionPushParamsSIR;
|
||||
}
|
||||
|
||||
export interface ServiceNowITOMActionParams {
|
||||
subAction: string;
|
||||
subActionParams: ExecutorSubActionAddEventParams;
|
||||
}
|
||||
|
||||
export interface ServiceNowConfig {
|
||||
apiUrl: string;
|
||||
isLegacy: boolean;
|
||||
|
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { ActionConnector } from '../../../../types';
|
||||
import { useChoices, UseChoices, UseChoicesProps } from './use_choices';
|
||||
import { getChoices } from './api';
|
||||
|
||||
jest.mock('./api');
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
|
||||
const getChoicesMock = getChoices as jest.Mock;
|
||||
|
||||
const actionConnector = {
|
||||
secrets: {
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
id: 'test',
|
||||
actionTypeId: '.servicenow',
|
||||
name: 'ServiceNow ITSM',
|
||||
isPreconfigured: false,
|
||||
config: {
|
||||
apiUrl: 'https://dev94428.service-now.com/',
|
||||
},
|
||||
} as ActionConnector;
|
||||
|
||||
const getChoicesResponse = [
|
||||
{
|
||||
dependent_value: '',
|
||||
label: 'Priviledge Escalation',
|
||||
value: 'Priviledge Escalation',
|
||||
element: 'category',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: 'Criminal activity/investigation',
|
||||
value: 'Criminal activity/investigation',
|
||||
element: 'category',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: 'Denial of Service',
|
||||
value: 'Denial of Service',
|
||||
element: 'category',
|
||||
},
|
||||
];
|
||||
|
||||
const useChoicesResponse = {
|
||||
isLoading: false,
|
||||
choices: { category: getChoicesResponse },
|
||||
};
|
||||
|
||||
describe('UseChoices', () => {
|
||||
const { services } = useKibanaMock();
|
||||
getChoicesMock.mockResolvedValue({
|
||||
data: getChoicesResponse,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const fields = ['category'];
|
||||
|
||||
it('init', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<UseChoicesProps, UseChoices>(() =>
|
||||
useChoices({
|
||||
http: services.http,
|
||||
actionConnector,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
fields,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current).toEqual(useChoicesResponse);
|
||||
});
|
||||
|
||||
it('returns an empty array if the field is not in response', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<UseChoicesProps, UseChoices>(() =>
|
||||
useChoices({
|
||||
http: services.http,
|
||||
actionConnector,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
fields: ['priority'],
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
choices: { priority: [], category: getChoicesResponse },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array when connector is not presented', async () => {
|
||||
const { result } = renderHook<UseChoicesProps, UseChoices>(() =>
|
||||
useChoices({
|
||||
http: services.http,
|
||||
actionConnector: undefined,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
fields,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
choices: { category: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('it displays an error when service fails', async () => {
|
||||
getChoicesMock.mockResolvedValue({
|
||||
status: 'error',
|
||||
service_message: 'An error occurred',
|
||||
});
|
||||
|
||||
const { waitForNextUpdate } = renderHook<UseChoicesProps, UseChoices>(() =>
|
||||
useChoices({
|
||||
http: services.http,
|
||||
actionConnector,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
fields,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
|
||||
text: 'An error occurred',
|
||||
title: 'Unable to get choices',
|
||||
});
|
||||
});
|
||||
|
||||
it('it displays an error when http throws an error', async () => {
|
||||
getChoicesMock.mockImplementation(() => {
|
||||
throw new Error('An error occurred');
|
||||
});
|
||||
|
||||
renderHook<UseChoicesProps, UseChoices>(() =>
|
||||
useChoices({
|
||||
http: services.http,
|
||||
actionConnector,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
fields,
|
||||
})
|
||||
);
|
||||
|
||||
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
|
||||
text: 'An error occurred',
|
||||
title: 'Unable to get choices',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { useCallback, useMemo, useState } from 'react';
|
||||
import { HttpSetup, ToastsApi } from 'kibana/public';
|
||||
|
||||
import { ActionConnector } from '../../../../types';
|
||||
import { Choice, Fields } from './types';
|
||||
import { useGetChoices } from './use_get_choices';
|
||||
|
||||
export interface UseChoicesProps {
|
||||
http: HttpSetup;
|
||||
toastNotifications: Pick<
|
||||
ToastsApi,
|
||||
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
|
||||
>;
|
||||
actionConnector?: ActionConnector;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface UseChoices {
|
||||
choices: Fields;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const useChoices = ({
|
||||
http,
|
||||
actionConnector,
|
||||
toastNotifications,
|
||||
fields,
|
||||
}: UseChoicesProps): UseChoices => {
|
||||
const defaultFields: Record<string, Choice[]> = useMemo(
|
||||
() => fields.reduce((acc, field) => ({ ...acc, [field]: [] }), {}),
|
||||
[fields]
|
||||
);
|
||||
const [choices, setChoices] = useState<Fields>(defaultFields);
|
||||
|
||||
const onChoicesSuccess = useCallback(
|
||||
(values: Choice[]) => {
|
||||
setChoices(
|
||||
values.reduce(
|
||||
(acc, value) => ({
|
||||
...acc,
|
||||
[value.element]: [...(acc[value.element] ?? []), value],
|
||||
}),
|
||||
defaultFields
|
||||
)
|
||||
);
|
||||
},
|
||||
[defaultFields]
|
||||
);
|
||||
|
||||
const { isLoading } = useGetChoices({
|
||||
http,
|
||||
toastNotifications,
|
||||
actionConnector,
|
||||
fields,
|
||||
onSuccess: onChoicesSuccess,
|
||||
});
|
||||
|
||||
return { choices, isLoading };
|
||||
};
|
|
@ -121,7 +121,7 @@ describe('useGetChoices', () => {
|
|||
it('it displays an error when service fails', async () => {
|
||||
getChoicesMock.mockResolvedValue({
|
||||
status: 'error',
|
||||
serviceMessage: 'An error occurred',
|
||||
service_message: 'An error occurred',
|
||||
});
|
||||
|
||||
const { waitForNextUpdate } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
|
||||
|
@ -162,4 +162,26 @@ describe('useGetChoices', () => {
|
|||
title: 'Unable to get choices',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array if the response is not an array', async () => {
|
||||
getChoicesMock.mockResolvedValue({
|
||||
status: 'ok',
|
||||
data: {},
|
||||
});
|
||||
|
||||
const { result } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
|
||||
useGetChoices({
|
||||
http: services.http,
|
||||
actionConnector: undefined,
|
||||
toastNotifications: services.notifications.toasts,
|
||||
fields,
|
||||
onSuccess,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
choices: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -60,15 +60,16 @@ export const useGetChoices = ({
|
|||
});
|
||||
|
||||
if (!didCancel.current) {
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setIsLoading(false);
|
||||
setChoices(res.data ?? []);
|
||||
setChoices(data);
|
||||
if (res.status && res.status === 'error') {
|
||||
toastNotifications.addDanger({
|
||||
title: i18n.CHOICES_API_ERROR,
|
||||
text: `${res.serviceMessage ?? res.message}`,
|
||||
text: `${res.service_message ?? res.message}`,
|
||||
});
|
||||
} else if (onSuccess) {
|
||||
onSuccess(res.data ?? []);
|
||||
onSuccess(data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -35,6 +35,7 @@ const enabledActionTypes = [
|
|||
'.server-log',
|
||||
'.servicenow',
|
||||
'.servicenow-sir',
|
||||
'.servicenow-itom',
|
||||
'.jira',
|
||||
'.resilient',
|
||||
'.slack',
|
||||
|
|
|
@ -175,6 +175,14 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo
|
|||
});
|
||||
}
|
||||
|
||||
if (pathName === '/api/global/em/jsonv2') {
|
||||
return sendResponse(response, {
|
||||
result: {
|
||||
'Default Bulk Endpoint': '1 events were inserted',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return an 400 error if endpoint is not supported
|
||||
response.statusCode = 400;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
|
|
|
@ -231,10 +231,6 @@ export default function jiraTest({ getService }: FtrProviderContext) {
|
|||
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
|
||||
expect(resp.body.connector_id).to.eql(simulatedActionId);
|
||||
expect(resp.body.status).to.eql('error');
|
||||
expect(resp.body.retry).to.eql(false);
|
||||
expect(resp.body.message).to.be(
|
||||
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -233,10 +233,6 @@ export default function resilientTest({ getService }: FtrProviderContext) {
|
|||
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
|
||||
expect(resp.body.connector_id).to.eql(simulatedActionId);
|
||||
expect(resp.body.status).to.eql('error');
|
||||
expect(resp.body.retry).to.eql(false);
|
||||
expect(resp.body.message).to.be(
|
||||
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,342 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import httpProxy from 'http-proxy';
|
||||
import expect from '@kbn/expect';
|
||||
import getPort from 'get-port';
|
||||
import http from 'http';
|
||||
|
||||
import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server';
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function serviceNowITOMTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const configService = getService('config');
|
||||
|
||||
const mockServiceNow = {
|
||||
config: {
|
||||
apiUrl: 'www.servicenowisinkibanaactions.com',
|
||||
},
|
||||
secrets: {
|
||||
password: 'elastic',
|
||||
username: 'changeme',
|
||||
},
|
||||
params: {
|
||||
subAction: 'addEvent',
|
||||
subActionParams: {
|
||||
source: 'A source',
|
||||
event_class: 'An event class',
|
||||
resource: 'C:',
|
||||
node: 'node.example.com',
|
||||
metric_name: 'Percentage Logical Disk Free Space',
|
||||
type: 'Disk space',
|
||||
severity: '4',
|
||||
description: 'desc',
|
||||
additional_info: '{"alert": "test"}',
|
||||
message_key: 'a key',
|
||||
time_of_event: '2021-10-13T10:51:44.981Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ServiceNow ITOM', () => {
|
||||
let simulatedActionId = '';
|
||||
let serviceNowSimulatorURL: string = '';
|
||||
let serviceNowServer: http.Server;
|
||||
let proxyServer: httpProxy | undefined;
|
||||
let proxyHaveBeenCalled = false;
|
||||
|
||||
before(async () => {
|
||||
serviceNowServer = await getServiceNowServer();
|
||||
const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) });
|
||||
if (!serviceNowServer.listening) {
|
||||
serviceNowServer.listen(availablePort);
|
||||
}
|
||||
serviceNowSimulatorURL = `http://localhost:${availablePort}`;
|
||||
proxyServer = await getHttpProxyServer(
|
||||
serviceNowSimulatorURL,
|
||||
configService.get('kbnTestServer.serverArgs'),
|
||||
() => {
|
||||
proxyHaveBeenCalled = true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
serviceNowServer.close();
|
||||
if (proxyServer) {
|
||||
proxyServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('ServiceNow ITOM - Action Creation', () => {
|
||||
it('should return 200 when creating a servicenow action successfully', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A servicenow action',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
config: {
|
||||
apiUrl: serviceNowSimulatorURL,
|
||||
},
|
||||
secrets: mockServiceNow.secrets,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
is_preconfigured: false,
|
||||
name: 'A servicenow action',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
apiUrl: serviceNowSimulatorURL,
|
||||
},
|
||||
});
|
||||
|
||||
const { body: fetchedAction } = await supertest
|
||||
.get(`/api/actions/connector/${createdAction.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(fetchedAction).to.eql({
|
||||
id: fetchedAction.id,
|
||||
is_preconfigured: false,
|
||||
name: 'A servicenow action',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
is_missing_secrets: false,
|
||||
config: {
|
||||
apiUrl: serviceNowSimulatorURL,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A servicenow action',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
config: {},
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A servicenow action',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
config: {
|
||||
apiUrl: 'http://servicenow.mynonexistent.com',
|
||||
},
|
||||
secrets: mockServiceNow.secrets,
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => {
|
||||
await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A servicenow action',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
config: {
|
||||
apiUrl: serviceNowSimulatorURL,
|
||||
},
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type secrets: [password]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceNow ITOM - Executor', () => {
|
||||
before(async () => {
|
||||
const { body } = await supertest
|
||||
.post('/api/actions/connector')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A servicenow simulator',
|
||||
connector_type_id: '.servicenow-itom',
|
||||
config: {
|
||||
apiUrl: serviceNowSimulatorURL,
|
||||
},
|
||||
secrets: mockServiceNow.secrets,
|
||||
});
|
||||
simulatedActionId = body.id;
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should handle failing with a simulated success without action', async () => {
|
||||
await supertest
|
||||
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {},
|
||||
})
|
||||
.then((resp: any) => {
|
||||
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
|
||||
expect(resp.body.connector_id).to.eql(simulatedActionId);
|
||||
expect(resp.body.status).to.eql('error');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failing with a simulated success without unsupported action', async () => {
|
||||
await supertest
|
||||
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: { subAction: 'non-supported' },
|
||||
})
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
connector_id: simulatedActionId,
|
||||
status: 'error',
|
||||
retry: false,
|
||||
message:
|
||||
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failing with a simulated success without subActionParams', async () => {
|
||||
await supertest
|
||||
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: { subAction: 'pushToService' },
|
||||
})
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
connector_id: simulatedActionId,
|
||||
status: 'error',
|
||||
retry: false,
|
||||
message:
|
||||
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChoices', () => {
|
||||
it('should fail when field is not provided', async () => {
|
||||
await supertest
|
||||
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'getChoices',
|
||||
subActionParams: {},
|
||||
},
|
||||
})
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
connector_id: simulatedActionId,
|
||||
status: 'error',
|
||||
retry: false,
|
||||
message:
|
||||
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subActionParams.fields]: expected value of type [array] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Execution', () => {
|
||||
// New connectors
|
||||
describe('Add event', () => {
|
||||
it('should add an event ', async () => {
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: mockServiceNow.params,
|
||||
})
|
||||
.expect(200);
|
||||
expect(result.status).to.eql('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChoices', () => {
|
||||
it('should get choices', async () => {
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction: 'getChoices',
|
||||
subActionParams: { fields: ['priority'] },
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(proxyHaveBeenCalled).to.equal(true);
|
||||
expect(result).to.eql({
|
||||
status: 'ok',
|
||||
connector_id: simulatedActionId,
|
||||
data: [
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '1 - Critical',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '2 - High',
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '3 - Moderate',
|
||||
value: '3',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '4 - Low',
|
||||
value: '4',
|
||||
},
|
||||
{
|
||||
dependent_value: '',
|
||||
label: '5 - Planning',
|
||||
value: '5',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -241,10 +241,6 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
|
|||
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
|
||||
expect(resp.body.connector_id).to.eql(simulatedActionId);
|
||||
expect(resp.body.status).to.eql('error');
|
||||
expect(resp.body.retry).to.eql(false);
|
||||
expect(resp.body.message).to.be(
|
||||
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -245,10 +245,6 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) {
|
|||
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
|
||||
expect(resp.body.connector_id).to.eql(simulatedActionId);
|
||||
expect(resp.body.status).to.eql('error');
|
||||
expect(resp.body.retry).to.eql(false);
|
||||
expect(resp.body.message).to.be(
|
||||
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -322,10 +322,6 @@ export default function swimlaneTest({ getService }: FtrProviderContext) {
|
|||
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
|
||||
expect(resp.body.connector_id).to.eql(simulatedActionId);
|
||||
expect(resp.body.status).to.eql('error');
|
||||
expect(resp.body.retry).to.eql(false);
|
||||
expect(resp.body.message).to.be(
|
||||
`error validating action params: undefined is not iterable (cannot read property Symbol(Symbol.iterator))`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo
|
|||
loadTestFile(require.resolve('./builtin_action_types/server_log'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/servicenow_sir'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/servicenow_itom'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/jira'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/resilient'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/slack'));
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue