[Alerts] ServiceNow SIR Connector (#88190)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2021-02-02 11:29:42 +02:00 committed by GitHub
parent bdca03dcfd
commit 7a45fc45e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2265 additions and 444 deletions

View file

@ -78,7 +78,7 @@ pageLoadAssetSize:
tileMap: 65337
timelion: 29920
transform: 41007
triggersActionsUi: 170001
triggersActionsUi: 186732
uiActions: 97717
uiActionsEnhanced: 313011
upgradeAssistant: 81241

View file

@ -70,12 +70,14 @@ Table of Contents
- [`params`](#params-6)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice)
- [`subActionParams (getFields)`](#subactionparams-getfields)
- [`subActionParams (getIncident)`](#subactionparams-getincident)
- [`subActionParams (getChoices)`](#subactionparams-getchoices)
- [Jira](#jira)
- [`config`](#config-7)
- [`secrets`](#secrets-7)
- [`params`](#params-7)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1)
- [`subActionParams (getIncident)`](#subactionparams-getincident)
- [`subActionParams (getIncident)`](#subactionparams-getincident-1)
- [`subActionParams (issueTypes)`](#subactionparams-issuetypes)
- [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype)
- [`subActionParams (issues)`](#subactionparams-issues)
@ -347,17 +349,18 @@ const result = await actionsClient.execute({
Kibana ships with a set of built-in action types:
| Type | Id | Description |
| ------------------------------- | ------------- | ------------------------------------------------------------------ |
| [Server log](#server-log) | `.server-log` | Logs messages to the Kibana log using Kibana's logger |
| [Email](#email) | `.email` | Sends an email using SMTP |
| [Slack](#slack) | `.slack` | Posts a message to a slack channel |
| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch |
| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT |
| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service |
| [ServiceNow](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow instance |
| [Jira](#jira) | `.jira` | Create or update an issue to a Jira instance |
| [IBM Resilient](#ibm-resilient) | `.resilient` | Create or update an incident to a IBM Resilient instance |
| Type | Id | Description |
| ------------------------------- | ----------------- | ------------------------------------------------------------------ |
| [Server log](#server-log) | `.server-log` | Logs messages to the Kibana log using Kibana's logger |
| [Email](#email) | `.email` | Sends an email using SMTP |
| [Slack](#slack) | `.slack` | Posts a message to a slack channel |
| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch |
| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT |
| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service |
| [ServiceNow ITSM](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow ITSM instance |
| [ServiceNow SIR](#servicenow) | `.servicenow-sir` | Create or update an incident to a ServiceNow SIR instance |
| [Jira](#jira) | `.jira` | Create or update an issue to a Jira instance |
| [IBM Resilient](#ibm-resilient) | `.resilient` | Create or update an incident to a IBM Resilient instance |
---
@ -549,9 +552,11 @@ For more details see [PagerDuty v2 event parameters](https://v2.developer.pagerd
## ServiceNow
ID: `.servicenow`
ServiceNow ITSM ID: `.servicenow`
The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI) to create and update ServiceNow incidents.
ServiceNow SIR ID: `.servicenow-sir`
The ServiceNow actions use the [V2 Table API](https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI) to create and update ServiceNow incidents. Both action types use the same `config`, `secrets`, and `params` schema.
### `config`
@ -568,10 +573,10 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a
### `params`
| Property | Description | Type |
| --------------- | --------------------------------------------------------------------- | ------ |
| subAction | The sub action to perform. It can be `getFields`, and `pushToService` | string |
| subActionParams | The parameters of the sub action | object |
| Property | Description | Type |
| --------------- | -------------------------------------------------------------------------------------------------- | ------ |
| subAction | The sub action to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices` | string |
| subActionParams | The parameters of the sub action | object |
#### `subActionParams (pushToService)`
@ -595,6 +600,19 @@ The following table describes the properties of the `incident` object.
No parameters for `getFields` sub-action. Provide an empty object `{}`.
#### `subActionParams (getIncident)`
| Property | Description | Type |
| ---------- | ------------------------------------- | ------ |
| externalId | The id of the incident in ServiceNow. | string |
#### `subActionParams (getChoices)`
| Property | Description | Type |
| -------- | ------------------------------------------------------------ | -------- |
| fields | An array of fields. Example: `[priority, category, impact]`. | string[] |
---
## Jira

View file

@ -14,7 +14,7 @@ import { getActionType as getPagerDutyActionType } from './pagerduty';
import { getActionType as getServerLogActionType } from './server_log';
import { getActionType as getSlackActionType } from './slack';
import { getActionType as getWebhookActionType } from './webhook';
import { getActionType as getServiceNowActionType } from './servicenow';
import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
import { getActionType as getJiraActionType } from './jira';
import { getActionType as getResilientActionType } from './resilient';
import { getActionType as getTeamsActionType } from './teams';
@ -38,7 +38,8 @@ export {
} from './webhook';
export {
ActionParamsType as ServiceNowActionParams,
ActionTypeId as ServiceNowActionTypeId,
ServiceNowITSMActionTypeId,
ServiceNowSIRActionTypeId,
} from './servicenow';
export { ActionParamsType as JiraActionParams, ActionTypeId as JiraActionTypeId } from './jira';
export {
@ -66,7 +67,8 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getServerLogActionType({ logger }));
actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowITSMActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowSIRActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities }));

View file

@ -5,7 +5,7 @@
*/
import { Logger } from '../../../../../../src/core/server';
import { externalServiceMock, apiParams, serviceNowCommonFields } from './mocks';
import { externalServiceMock, apiParams, serviceNowCommonFields, serviceNowChoices } from './mocks';
import { ExternalService } from './types';
import { api } from './api';
let mockedLogger: jest.Mocked<Logger>;
@ -235,4 +235,14 @@ describe('api', () => {
expect(res).toEqual(serviceNowCommonFields);
});
});
describe('getChoices', () => {
test('it returns the fields correctly', async () => {
const res = await api.getChoices({
externalService,
params: { fields: ['priority'] },
});
expect(res).toEqual(serviceNowChoices);
});
});
});

View file

@ -5,6 +5,8 @@
*/
import {
ExternalServiceApi,
GetChoicesHandlerArgs,
GetChoicesResponse,
GetCommonFieldsHandlerArgs,
GetCommonFieldsResponse,
GetIncidentApiHandlerArgs,
@ -71,7 +73,16 @@ const getFieldsHandler = async ({
return res;
};
const getChoicesHandler = async ({
externalService,
params,
}: GetChoicesHandlerArgs): Promise<GetChoicesResponse> => {
const res = await externalService.getChoices(params.fields);
return res;
};
export const api: ExternalServiceApi = {
getChoices: getChoicesHandler,
getFields: getFieldsHandler,
getIncident: getIncidentHandler,
handshake: handshakeHandler,

View file

@ -11,7 +11,8 @@ import { validate } from './validators';
import {
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
ExecutorParamsSchema,
ExecutorParamsSchemaITSM,
ExecutorParamsSchemaSIR,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
@ -27,18 +28,26 @@ import {
PushToServiceResponse,
ExecutorSubActionCommonFieldsParams,
ServiceNowExecutorResultData,
ExecutorSubActionGetChoicesParams,
} from './types';
export type ActionParamsType = TypeOf<typeof ExecutorParamsSchema>;
export type ActionParamsType =
| TypeOf<typeof ExecutorParamsSchemaITSM>
| TypeOf<typeof ExecutorParamsSchemaSIR>;
interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}
export const ActionTypeId = '.servicenow';
const serviceNowITSMTable = 'incident';
const serviceNowSIRTable = 'sn_si_incident';
export const ServiceNowITSMActionTypeId = '.servicenow';
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
// action type definition
export function getActionType(
export function getServiceNowITSMActionType(
params: GetActionTypeParams
): ActionType<
ServiceNowPublicConfigurationType,
@ -48,9 +57,9 @@ export function getActionType(
> {
const { logger, configurationUtilities } = params;
return {
id: ActionTypeId,
id: ServiceNowITSMActionTypeId,
minimumLicenseRequired: 'platinum',
name: i18n.NAME,
name: i18n.SERVICENOW_ITSM,
validate: {
config: schema.object(ExternalIncidentServiceConfiguration, {
validate: curry(validate.config)(configurationUtilities),
@ -58,19 +67,46 @@ export function getActionType(
secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
validate: curry(validate.secrets)(configurationUtilities),
}),
params: ExecutorParamsSchema,
params: ExecutorParamsSchemaITSM,
},
executor: curry(executor)({ logger, configurationUtilities }),
executor: curry(executor)({ logger, configurationUtilities, table: serviceNowITSMTable }),
};
}
export function getServiceNowSIRActionType(
params: GetActionTypeParams
): ActionType<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
> {
const { logger, configurationUtilities } = params;
return {
id: ServiceNowSIRActionTypeId,
minimumLicenseRequired: 'platinum',
name: i18n.SERVICENOW_SIR,
validate: {
config: schema.object(ExternalIncidentServiceConfiguration, {
validate: curry(validate.config)(configurationUtilities),
}),
secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
validate: curry(validate.secrets)(configurationUtilities),
}),
params: ExecutorParamsSchemaSIR,
},
executor: curry(executor)({ logger, configurationUtilities, table: serviceNowSIRTable }),
};
}
// action executor
const supportedSubActions: string[] = ['getFields', 'pushToService'];
const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident'];
async function executor(
{
logger,
configurationUtilities,
}: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities },
table,
}: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; table: string },
execOptions: ActionTypeExecutorOptions<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
@ -82,6 +118,7 @@ async function executor(
let data: ServiceNowExecutorResultData | null = null;
const externalService = createExternalService(
table,
{
config,
secrets,
@ -122,5 +159,13 @@ async function executor(
});
}
if (subAction === 'getChoices') {
const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams;
data = await api.getChoices({
externalService,
params: getChoicesParams,
});
}
return { status: 'ok', data: data ?? {}, actionId };
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { ExternalService, ExecutorSubActionPushParams } from './types';
export const serviceNowCommonFields = [
{
@ -33,8 +33,43 @@ export const serviceNowCommonFields = [
element: 'sys_updated_by',
},
];
export const serviceNowChoices = [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element: 'priority',
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element: 'priority',
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element: 'priority',
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element: 'priority',
},
{
dependent_value: '',
label: '5 - Planning',
value: '5',
element: 'priority',
},
];
const createMock = (): jest.Mocked<ExternalService> => {
const service = {
getChoices: jest.fn().mockImplementation(() => Promise.resolve(serviceNowChoices)),
getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)),
getIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
@ -89,8 +124,6 @@ const executorParams: ExecutorSubActionPushParams = {
],
};
const apiParams: PushToServiceApiParams = {
...executorParams,
};
const apiParams = executorParams;
export { externalServiceMock, executorParams, apiParams };

View file

@ -28,25 +28,48 @@ export const ExecutorSubActionSchema = schema.oneOf([
schema.literal('getIncident'),
schema.literal('pushToService'),
schema.literal('handshake'),
schema.literal('getChoices'),
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
const CommentsSchema = schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
);
const CommonAttributes = {
short_description: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
};
// Schema for ServiceNow Incident Management (ITSM)
export const ExecutorSubActionPushParamsSchemaITSM = schema.object({
incident: schema.object({
short_description: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
...CommonAttributes,
severity: schema.nullable(schema.string()),
urgency: schema.nullable(schema.string()),
impact: schema.nullable(schema.string()),
}),
comments: schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
),
comments: CommentsSchema,
});
// Schema for ServiceNow Security Incident Response (SIR)
export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
incident: schema.object({
...CommonAttributes,
category: schema.nullable(schema.string()),
dest_ip: schema.nullable(schema.string()),
malware_hash: schema.nullable(schema.string()),
malware_url: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
source_ip: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
}),
comments: CommentsSchema,
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
@ -56,8 +79,12 @@ export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
// Reserved for future implementation
export const ExecutorSubActionHandshakeParamsSchema = schema.object({});
export const ExecutorSubActionCommonFieldsParamsSchema = schema.object({});
export const ExecutorSubActionGetChoicesParamsSchema = schema.object({
fields: schema.arrayOf(schema.string()),
});
export const ExecutorParamsSchema = schema.oneOf([
// Executor parameters for ServiceNow Incident Management (ITSM)
export const ExecutorParamsSchemaITSM = schema.oneOf([
schema.object({
subAction: schema.literal('getFields'),
subActionParams: ExecutorSubActionCommonFieldsParamsSchema,
@ -72,6 +99,34 @@ export const ExecutorParamsSchema = schema.oneOf([
}),
schema.object({
subAction: schema.literal('pushToService'),
subActionParams: ExecutorSubActionPushParamsSchema,
subActionParams: ExecutorSubActionPushParamsSchemaITSM,
}),
schema.object({
subAction: schema.literal('getChoices'),
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
}),
]);
// Executor parameters for ServiceNow Security Incident Response (SIR)
export const ExecutorParamsSchemaSIR = schema.oneOf([
schema.object({
subAction: schema.literal('getFields'),
subActionParams: ExecutorSubActionCommonFieldsParamsSchema,
}),
schema.object({
subAction: schema.literal('getIncident'),
subActionParams: ExecutorSubActionGetIncidentParamsSchema,
}),
schema.object({
subAction: schema.literal('handshake'),
subActionParams: ExecutorSubActionHandshakeParamsSchema,
}),
schema.object({
subAction: schema.literal('pushToService'),
subActionParams: ExecutorSubActionPushParamsSchemaSIR,
}),
schema.object({
subAction: schema.literal('getChoices'),
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
}),
]);

View file

@ -12,7 +12,7 @@ import { ExternalService } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
import { serviceNowCommonFields } from './mocks';
import { serviceNowCommonFields, serviceNowChoices } from './mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
@ -29,12 +29,14 @@ axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
const patchMock = utils.patch as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
const table = 'incident';
describe('ServiceNow service', () => {
let service: ExternalService;
beforeAll(() => {
beforeEach(() => {
service = createExternalService(
table,
{
// The trailing slash at the end of the url is intended.
// All API calls need to have the trailing slash removed.
@ -54,6 +56,7 @@ describe('ServiceNow service', () => {
test('throws without url', () => {
expect(() =>
createExternalService(
table,
{
config: { apiUrl: null },
secrets: { username: 'admin', password: 'admin' },
@ -67,6 +70,7 @@ describe('ServiceNow service', () => {
test('throws without username', () => {
expect(() =>
createExternalService(
table,
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: 'admin' },
@ -80,6 +84,7 @@ describe('ServiceNow service', () => {
test('throws without password', () => {
expect(() =>
createExternalService(
table,
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: undefined },
@ -114,6 +119,30 @@ describe('ServiceNow service', () => {
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
requestMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01' } },
}));
await service.getIncident('1');
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
@ -122,6 +151,17 @@ describe('ServiceNow service', () => {
'Unable to get incident with id 1. Error: An error has occurred'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
describe('createIncident', () => {
@ -161,6 +201,39 @@ describe('ServiceNow service', () => {
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
requestMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
}));
const res = await service.createIncident({
incident: { short_description: 'title', description: 'desc' },
});
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident',
method: 'post',
data: { short_description: 'title', description: 'desc' },
});
expect(res.url).toEqual(
'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
@ -174,6 +247,17 @@ describe('ServiceNow service', () => {
'[Action][ServiceNow]: Unable to create incident. Error: An error has occurred'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
describe('updateIncident', () => {
@ -214,6 +298,39 @@ describe('ServiceNow service', () => {
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
patchMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
}));
const res = await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' },
});
expect(patchMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
data: { short_description: 'title', description: 'desc' },
});
expect(res.url).toEqual(
'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
);
});
test('it should throw an error', async () => {
patchMock.mockImplementation(() => {
throw new Error('An error has occurred');
@ -228,6 +345,7 @@ describe('ServiceNow service', () => {
'[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred'
);
});
test('it creates the comment correctly', async () => {
patchMock.mockImplementation(() => ({
data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } },
@ -245,6 +363,17 @@ describe('ServiceNow service', () => {
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11',
});
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
describe('getFields', () => {
@ -259,9 +388,10 @@ describe('ServiceNow service', () => {
logger,
configurationUtilities,
url:
'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
test('it returns common fields correctly', async () => {
requestMock.mockImplementation(() => ({
data: { result: serviceNowCommonFields },
@ -270,6 +400,31 @@ describe('ServiceNow service', () => {
expect(res).toEqual(serviceNowCommonFields);
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
requestMock.mockImplementation(() => ({
data: { result: serviceNowCommonFields },
}));
await service.getFields();
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url:
'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
@ -278,5 +433,87 @@ describe('ServiceNow service', () => {
'[Action][ServiceNow]: Unable to get fields. Error: An error has occurred'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
describe('getChoices', () => {
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: { result: serviceNowChoices },
}));
await service.getChoices(['priority', 'category']);
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url:
'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
});
});
test('it returns common fields correctly', async () => {
requestMock.mockImplementation(() => ({
data: { result: serviceNowChoices },
}));
const res = await service.getChoices(['priority']);
expect(res).toEqual(serviceNowChoices);
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
requestMock.mockImplementation(() => ({
data: { result: serviceNowChoices },
}));
await service.getChoices(['priority', 'category']);
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url:
'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.getChoices(['priority'])).rejects.toThrow(
'[Action][ServiceNow]: Unable to get choices. Error: An error has occurred'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
});

View file

@ -15,13 +15,10 @@ import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios
import { ActionsConfigurationUtilities } from '../../actions_config';
const API_VERSION = 'v2';
const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`;
const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`;
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`;
export const createExternalService = (
table: string,
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
@ -30,24 +27,36 @@ export const createExternalService = (
const { username, password } = secrets as ServiceNowSecretConfigurationType;
if (!url || !username || !password) {
throw Error(`[Action]${i18n.NAME}: Wrong configuration.`);
throw Error(`[Action]${i18n.SERVICENOW}: Wrong configuration.`);
}
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const incidentUrl = `${urlWithoutTrailingSlash}/${INCIDENT_URL}`;
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`;
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`;
const axiosInstance = axios.create({
auth: { username, password },
});
const getIncidentViewURL = (id: string) => {
return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}${id}`;
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`;
};
const getChoicesURL = (fields: string[]) => {
const elements = fields
.slice(1)
.reduce((acc, field) => `${acc}^ORelement=${field}`, `element=${fields[0]}`);
return `${choicesUrl}?sysparm_query=name=task^ORname=${table}^${elements}&sysparm_fields=label,value,dependent_value,element`;
};
const checkInstance = (res: AxiosResponse) => {
if (res.status === 200 && res.data.result == null) {
throw new Error(
`There is an issue with your Service Now Instance. Please check ${res.request.connection.servername}`
`There is an issue with your Service Now Instance. Please check ${
res.request?.connection?.servername ?? ''
}.`
);
}
};
@ -64,7 +73,10 @@ export const createExternalService = (
return { ...res.data.result };
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`)
getErrorMessage(
i18n.SERVICENOW,
`Unable to get incident with id ${id}. Error: ${error.message}`
)
);
}
};
@ -82,7 +94,10 @@ export const createExternalService = (
return res.data.result.length > 0 ? { ...res.data.result } : undefined;
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to find incidents by query. Error: ${error.message}`)
getErrorMessage(
i18n.SERVICENOW,
`Unable to find incidents by query. Error: ${error.message}`
)
);
}
};
@ -106,7 +121,7 @@ export const createExternalService = (
};
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`)
getErrorMessage(i18n.SERVICENOW, `Unable to create incident. Error: ${error.message}`)
);
}
};
@ -130,7 +145,7 @@ export const createExternalService = (
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
i18n.SERVICENOW,
`Unable to update incident with id ${incidentId}. Error: ${error.message}`
)
);
@ -148,7 +163,26 @@ export const createExternalService = (
checkInstance(res);
return res.data.result.length > 0 ? res.data.result : [];
} catch (error) {
throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}`));
throw new Error(
getErrorMessage(i18n.SERVICENOW, `Unable to get fields. Error: ${error.message}`)
);
}
};
const getChoices = async (fields: string[]) => {
try {
const res = await request({
axios: axiosInstance,
url: getChoicesURL(fields),
logger,
configurationUtilities,
});
checkInstance(res);
return res.data.result;
} catch (error) {
throw new Error(
getErrorMessage(i18n.SERVICENOW, `Unable to get choices. Error: ${error.message}`)
);
}
};
@ -158,5 +192,6 @@ export const createExternalService = (
getFields,
getIncident,
updateIncident,
getChoices,
};
};

View file

@ -6,10 +6,18 @@
import { i18n } from '@kbn/i18n';
export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', {
export const SERVICENOW = i18n.translate('xpack.actions.builtin.serviceNowTitle', {
defaultMessage: 'ServiceNow',
});
export const SERVICENOW_ITSM = i18n.translate('xpack.actions.builtin.serviceNowITSMTitle', {
defaultMessage: 'ServiceNow ITSM',
});
export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSIRTitle', {
defaultMessage: 'ServiceNow SIR',
});
export const ALLOWED_HOSTS_ERROR = (message: string) =>
i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', {
defaultMessage: 'error configuring connector action: {message}',

View file

@ -8,13 +8,16 @@
import { TypeOf } from '@kbn/config-schema';
import {
ExecutorParamsSchema,
ExecutorParamsSchemaITSM,
ExecutorSubActionCommonFieldsParamsSchema,
ExecutorSubActionGetIncidentParamsSchema,
ExecutorSubActionHandshakeParamsSchema,
ExecutorSubActionPushParamsSchema,
ExecutorSubActionPushParamsSchemaITSM,
ExternalIncidentServiceConfigurationSchema,
ExternalIncidentServiceSecretConfigurationSchema,
ExecutorParamsSchemaSIR,
ExecutorSubActionPushParamsSchemaSIR,
ExecutorSubActionGetChoicesParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { Logger } from '../../../../../../src/core/server';
@ -30,14 +33,29 @@ export type ExecutorSubActionCommonFieldsParams = TypeOf<
typeof ExecutorSubActionCommonFieldsParamsSchema
>;
export type ServiceNowExecutorResultData = PushToServiceResponse | GetCommonFieldsResponse;
export type ExecutorSubActionGetChoicesParams = TypeOf<
typeof ExecutorSubActionGetChoicesParamsSchema
>;
export type ServiceNowExecutorResultData =
| PushToServiceResponse
| GetCommonFieldsResponse
| GetChoicesResponse;
export interface CreateCommentRequest {
[key: string]: string;
}
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type ExecutorParams =
| TypeOf<typeof ExecutorParamsSchemaITSM>
| TypeOf<typeof ExecutorParamsSchemaSIR>;
export type ExecutorSubActionPushParamsITSM = TypeOf<typeof ExecutorSubActionPushParamsSchemaITSM>;
export type ExecutorSubActionPushParamsSIR = TypeOf<typeof ExecutorSubActionPushParamsSchemaSIR>;
export type ExecutorSubActionPushParams =
| ExecutorSubActionPushParamsITSM
| ExecutorSubActionPushParamsSIR;
export interface ExternalServiceCredentials {
config: Record<string, unknown>;
@ -62,14 +80,17 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
export type ExternalServiceParams = Record<string, unknown>;
export interface ExternalService {
getFields: () => Promise<GetCommonFieldsResponse>;
getChoices: (fields: string[]) => Promise<GetChoicesResponse>;
getIncident: (id: string) => Promise<ExternalServiceParams | undefined>;
getFields: () => Promise<GetCommonFieldsResponse>;
createIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
findIncidents: (params?: Record<string, string>) => Promise<ExternalServiceParams[] | undefined>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
export type PushToServiceApiParamsITSM = ExecutorSubActionPushParamsITSM;
export type PushToServiceApiParamsSIR = ExecutorSubActionPushParamsSIR;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
@ -83,7 +104,17 @@ export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export type ServiceNowITSMIncident = Omit<
TypeOf<typeof ExecutorSubActionPushParamsSchemaITSM>['incident'],
'externalId'
>;
export type ServiceNowSIRIncident = Omit<
TypeOf<typeof ExecutorSubActionPushParamsSchemaSIR>['incident'],
'externalId'
>;
export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
@ -104,13 +135,29 @@ export interface ExternalServiceFields {
max_length: string;
element: string;
}
export interface ExternalServiceChoices {
value: string;
label: string;
dependent_value: string;
element: string;
}
export type GetCommonFieldsResponse = ExternalServiceFields[];
export type GetChoicesResponse = ExternalServiceChoices[];
export interface GetCommonFieldsHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionCommonFieldsParams;
}
export interface GetChoicesHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetChoicesParams;
}
export interface ExternalServiceApi {
getChoices: (args: GetChoicesHandlerArgs) => Promise<GetChoicesResponse>;
getFields: (args: GetCommonFieldsHandlerArgs) => Promise<GetCommonFieldsResponse>;
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;

View file

@ -35,7 +35,8 @@ export type {
SlackActionParams,
WebhookActionTypeId,
WebhookActionParams,
ServiceNowActionTypeId,
ServiceNowITSMActionTypeId,
ServiceNowSIRActionTypeId,
ServiceNowActionParams,
JiraActionTypeId,
JiraActionParams,

View file

@ -16,8 +16,8 @@ import {
Incident as ResilientIncident,
} from '../../../../actions/server/builtin_action_types/resilient/types';
import {
PushToServiceApiParams as ServiceNowPushToServiceApiParams,
Incident as ServiceNowIncident,
PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams,
ServiceNowITSMIncident,
} from '../../../../actions/server/builtin_action_types/servicenow/types';
import { ResilientFieldsRT } from './resilient';
import { ServiceNowFieldsRT } from './servicenow';
@ -33,13 +33,13 @@ export interface ElasticUser {
export {
JiraPushToServiceApiParams,
ResilientPushToServiceApiParams,
ServiceNowPushToServiceApiParams,
ServiceNowITSMPushToServiceApiParams,
};
export type Incident = JiraIncident | ResilientIncident | ServiceNowIncident;
export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident;
export type PushToServiceApiParams =
| JiraPushToServiceApiParams
| ResilientPushToServiceApiParams
| ServiceNowPushToServiceApiParams;
| ServiceNowITSMPushToServiceApiParams;
const ActionTypeRT = rt.union([
rt.literal('append'),

View file

@ -19,7 +19,7 @@ import {
PrepareFieldsForTransformArgs,
PushToServiceApiParams,
ResilientPushToServiceApiParams,
ServiceNowPushToServiceApiParams,
ServiceNowITSMPushToServiceApiParams,
SimpleComment,
Transformer,
TransformerArgs,
@ -105,7 +105,11 @@ export const serviceFormatter = (
thirdPartyName: 'Resilient',
};
case ConnectorTypes.servicenow:
const { severity, urgency, impact } = params as ServiceNowPushToServiceApiParams['incident'];
const {
severity,
urgency,
impact,
} = params as ServiceNowITSMPushToServiceApiParams['incident'];
return {
incident: { severity, urgency, impact },
thirdPartyName: 'ServiceNow',

View file

@ -40,7 +40,7 @@ describe('Mapping', () => {
wrappingComponent: TestProviders,
});
expect(wrapper.find('[data-test-subj="field-mapping-desc"]').first().text()).toBe(
'Field mappings require an established connection to ServiceNow. Please check your connection credentials.'
'Field mappings require an established connection to ServiceNow ITSM. Please check your connection credentials.'
);
});
});

View file

@ -7,14 +7,14 @@
/* eslint-disable @kbn/eslint/no-restricted-paths */
import {
ServiceNowConnectorConfiguration,
ServiceNowITSMConnectorConfiguration,
JiraConnectorConfiguration,
ResilientConnectorConfiguration,
} from '../../../../../triggers_actions_ui/public/common';
import { ConnectorConfiguration } from './types';
export const connectorsConfiguration: Record<string, ConnectorConfiguration> = {
'.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration,
'.servicenow': ServiceNowITSMConnectorConfiguration as ConnectorConfiguration,
'.jira': JiraConnectorConfiguration as ConnectorConfiguration,
'.resilient': ResilientConnectorConfiguration as ConnectorConfiguration,
};

View file

@ -22,5 +22,4 @@ export interface ThirdPartyField {
export interface ConnectorConfiguration extends ActionType {
logo: string;
fields: Record<string, ThirdPartyField>;
}

View file

@ -4784,7 +4784,6 @@
"xpack.actions.builtin.pagerdutyTitle": "PagerDuty",
"xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "メッセージのロギングエラー",
"xpack.actions.builtin.serverLogTitle": "サーバーログ",
"xpack.actions.builtin.servicenowTitle": "ServiceNow",
"xpack.actions.builtin.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー",
"xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "slack メッセージの投稿エラー、 {retryString} で再試行",
"xpack.actions.builtin.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行",
@ -21161,7 +21160,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "タイミング",
"xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "変数を追加",
"xpack.triggersActionsUI.components.addMessageVariables.addVariableTitle": "アラート変数を追加",
"xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField": "説明が必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField": "短い説明が必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "メールに送信",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.configureAccountsHelpLabel": "電子メールアカウントの構成",
@ -21292,35 +21290,16 @@
"xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logLevelFieldLabel": "レベル",
"xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logMessageFieldLabel": "メッセージ",
"xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText": "Kibana ログにメッセージを追加します。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.actionTypeTitle": "ServiceNow",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiTokenTextFieldLabel": "APIトークン",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel": "URL",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.authenticationLabel": "認証",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel": "追加のコメント",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.descriptionTextAreaFieldLabel": "説明",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.emailTextFieldLabel": "メール",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.impactSelectFieldLabel": "インパクト",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL が無効です。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldComments": "コメント",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldDescription": "説明",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldShortDescription": "短い説明",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "パスワード",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "ユーザー名とパスワードは暗号化されます。これらのフィールドの値を再入力してください。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.rememberValuesLabel": "これらの値を覚えておいてください。コネクターを編集するたびに再入力する必要があります。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiTokenTextField": "API トークンが必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiUrlTextField": "URL が必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField": "電子メールが必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredPasswordTextField": "パスワードが必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "ユーザー名が必要です。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL は https:// から始める必要があります。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText": "ServiceNow でインシデントを作成します。",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel": "深刻度",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel": "高",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel": "低",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel": "中",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title": "インシデント",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel": "短い説明(必須)",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.urgencySelectFieldLabel": "緊急",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成",
"xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle": "Slack に送信",

View file

@ -4789,7 +4789,6 @@
"xpack.actions.builtin.pagerdutyTitle": "PagerDuty",
"xpack.actions.builtin.serverLog.errorLoggingErrorMessage": "记录消息时出错",
"xpack.actions.builtin.serverLogTitle": "服务器日志",
"xpack.actions.builtin.servicenowTitle": "ServiceNow",
"xpack.actions.builtin.slack.errorPostingErrorMessage": "发布 slack 消息时出错",
"xpack.actions.builtin.slack.errorPostingRetryDateErrorMessage": "发布 Slack 消息时出错,在 {retryString} 重试",
"xpack.actions.builtin.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试",
@ -21212,7 +21211,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "当",
"xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "添加变量",
"xpack.triggersActionsUI.components.addMessageVariables.addVariableTitle": "添加告警变量",
"xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField": "“描述”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField": "“简短描述”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle": "发送到电子邮件",
"xpack.triggersActionsUI.components.builtinActionTypes.emailAction.configureAccountsHelpLabel": "配置电子邮件帐户",
@ -21343,35 +21341,16 @@
"xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logLevelFieldLabel": "级别",
"xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logMessageFieldLabel": "消息",
"xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText": "将消息添加到 Kibana 日志。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.actionTypeTitle": "ServiceNow",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiTokenTextFieldLabel": "Api 令牌",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel": "URL",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.authenticationLabel": "身份验证",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel": "其他注释",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.descriptionTextAreaFieldLabel": "描述",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.emailTextFieldLabel": "电子邮件",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.impactSelectFieldLabel": "影响",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL 无效。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldComments": "注释",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldDescription": "描述",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldShortDescription": "简短描述",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "密码",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "用户名和密码已加密。请为这些字段重新输入值。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.rememberValuesLabel": "请记住这些值。每次编辑连接器时都必须重新输入。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiTokenTextField": "“Api 令牌”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiUrlTextField": "“URL”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField": "“电子邮件”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredPasswordTextField": "“密码”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "“用户名”必填。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL 必须以 https:// 开头。",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText": "在 ServiceNow 中创建事件。",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel": "严重性",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel": "高",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel": "低",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel": "中",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title": "事件",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel": "简短描述(必填)",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.urgencySelectFieldLabel": "紧急性",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例",
"xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle": "发送到 Slack",

View file

@ -12,7 +12,7 @@ import { getPagerDutyActionType } from './pagerduty';
import { getWebhookActionType } from './webhook';
import { TypeRegistry } from '../../type_registry';
import { ActionTypeModel } from '../../../types';
import { getServiceNowActionType } from './servicenow';
import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
import { getJiraActionType } from './jira';
import { getResilientActionType } from './resilient';
import { getTeamsActionType } from './teams';
@ -28,7 +28,8 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getIndexActionType());
actionTypeRegistry.register(getPagerDutyActionType());
actionTypeRegistry.register(getWebhookActionType());
actionTypeRegistry.register(getServiceNowActionType());
actionTypeRegistry.register(getServiceNowITSMActionType());
actionTypeRegistry.register(getServiceNowSIRActionType());
actionTypeRegistry.register(getJiraActionType());
actionTypeRegistry.register(getResilientActionType());
actionTypeRegistry.register(getTeamsActionType());

View file

@ -8,6 +8,7 @@ import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import { getIssueTypes, getFieldsByIssueType, getIssues, getIssue } from './api';
const issueTypesResponse = {
status: 'ok',
data: {
projects: [
{
@ -24,9 +25,11 @@ const issueTypesResponse = {
},
],
},
actionId: 'test',
};
const fieldsResponse = {
status: 'ok',
data: {
projects: [
{
@ -70,13 +73,18 @@ const fieldsResponse = {
],
},
],
actionId: 'test',
},
};
const issueResponse = {
id: '10267',
key: 'RJ-107',
fields: { summary: 'Test title' },
status: 'ok',
data: {
id: '10267',
key: 'RJ-107',
fields: { summary: 'Test title' },
},
actionId: 'test',
};
const issuesResponse = [issueResponse];

View file

@ -15,24 +15,4 @@ export const connectorConfiguration = {
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'gold',
fields: {
summary: {
label: i18n.MAPPING_FIELD_SUMMARY,
validSourceFields: ['title', 'description'],
defaultSourceField: 'title',
defaultActionType: 'overwrite',
},
description: {
label: i18n.MAPPING_FIELD_DESC,
validSourceFields: ['title', 'description'],
defaultSourceField: 'description',
defaultActionType: 'overwrite',
},
comments: {
label: i18n.MAPPING_FIELD_COMMENTS,
validSourceFields: ['comments'],
defaultSourceField: 'comments',
defaultActionType: 'append',
},
},
};

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import { getIncidentTypes, getSeverity } from './api';
const incidentTypesResponse = {
status: 'ok',
data: [
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
{ id: 21, name: 'Denial of Service' },
{ id: 6, name: 'Improper disposal: digital asset(s)' },
{ id: 7, name: 'Improper disposal: documents / files' },
{ id: 4, name: 'Lost documents / files / records' },
{ id: 3, name: 'Lost PC / laptop / tablet' },
{ id: 1, name: 'Lost PDA / smartphone' },
{ id: 8, name: 'Lost storage device / media' },
{ id: 19, name: 'Malware' },
{ id: 23, name: 'Not an Issue' },
{ id: 18, name: 'Other' },
{ id: 22, name: 'Phishing' },
{ id: 11, name: 'Stolen documents / files / records' },
{ id: 12, name: 'Stolen PC / laptop / tablet' },
{ id: 13, name: 'Stolen PDA / smartphone' },
{ id: 14, name: 'Stolen storage device / media' },
{ id: 20, name: 'System Intrusion' },
{ id: 16, name: 'TBD / Unknown' },
{ id: 15, name: 'Vendor / 3rd party error' },
],
actionId: 'test',
};
const severityResponse = {
status: 'ok',
data: [
{ id: 4, name: 'Low' },
{ id: 5, name: 'Medium' },
{ id: 6, name: 'High' },
],
actionId: 'test',
};
describe('Resilient API', () => {
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('getIncidentTypes', () => {
test('should call get choices API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(incidentTypesResponse);
const res = await getIncidentTypes({
http,
signal: abortCtrl.signal,
connectorId: 'test',
});
expect(res).toEqual(incidentTypesResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"incidentTypes","subActionParams":{}}}',
signal: abortCtrl.signal,
});
});
});
describe('getSeverity', () => {
test('should call get choices API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(severityResponse);
const res = await getSeverity({
http,
signal: abortCtrl.signal,
connectorId: 'test',
});
expect(res).toEqual(severityResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"severity","subActionParams":{}}}',
signal: abortCtrl.signal,
});
});
});
});

View file

@ -15,24 +15,4 @@ export const connectorConfiguration = {
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'platinum',
fields: {
name: {
label: i18n.MAPPING_FIELD_NAME,
validSourceFields: ['title', 'description'],
defaultSourceField: 'title',
defaultActionType: 'overwrite',
},
description: {
label: i18n.MAPPING_FIELD_DESC,
validSourceFields: ['title', 'description'],
defaultSourceField: 'description',
defaultActionType: 'overwrite',
},
comments: {
label: i18n.MAPPING_FIELD_COMMENTS,
validSourceFields: ['comments'],
defaultSourceField: 'comments',
defaultActionType: 'append',
},
},
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import { getChoices } from './api';
const choicesResponse = {
status: 'ok',
data: [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element: 'priority',
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element: 'priority',
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element: 'priority',
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element: 'priority',
},
{
dependent_value: '',
label: '5 - Planning',
value: '5',
element: 'priority',
},
],
};
describe('ServiceNow API', () => {
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('getChoices', () => {
test('should call get choices API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(choicesResponse);
const res = await getChoices({
http,
signal: abortCtrl.signal,
connectorId: 'test',
fields: ['priority'],
});
expect(res).toEqual(choicesResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}',
signal: abortCtrl.signal,
});
});
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'kibana/public';
import { BASE_ACTION_API_PATH } from '../../../constants';
export async function getChoices({
http,
signal,
connectorId,
fields,
}: {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
fields: string[];
}): Promise<Record<string, any>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'getChoices', subActionParams: { fields } },
}),
signal,
});
}

View file

@ -7,32 +7,24 @@
import * as i18n from './translations';
import logo from './logo.svg';
export const connectorConfiguration = {
export const serviceNowITSMConfiguration = {
id: '.servicenow',
name: i18n.SERVICENOW_TITLE,
name: i18n.SERVICENOW_ITSM_TITLE,
desc: i18n.SERVICENOW_ITSM_DESC,
logo,
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'platinum',
};
export const serviceNowSIRConfiguration = {
id: '.servicenow-sir',
name: i18n.SERVICENOW_SIR_TITLE,
desc: i18n.SERVICENOW_SIR_DESC,
logo,
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'platinum',
fields: {
short_description: {
label: i18n.MAPPING_FIELD_SHORT_DESC,
validSourceFields: ['title', 'description'],
defaultSourceField: 'title',
defaultActionType: 'overwrite',
},
description: {
label: i18n.MAPPING_FIELD_DESC,
validSourceFields: ['title', 'description'],
defaultSourceField: 'description',
defaultActionType: 'overwrite',
},
comments: {
label: i18n.MAPPING_FIELD_COMMENTS,
validSourceFields: ['comments'],
defaultSourceField: 'comments',
defaultActionType: 'append',
},
},
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { getActionType as getServiceNowActionType } from './servicenow';
export { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';

View file

@ -8,102 +8,110 @@ import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { ServiceNowActionConnector } from './types';
const ACTION_TYPE_ID = '.servicenow';
let actionTypeModel: ActionTypeModel;
const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
let actionTypeRegistry: TypeRegistry<ActionTypeModel>;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
test('action type static data is as expected', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
test(`${id}: action type static data is as expected`, () => {
const actionTypeModel = actionTypeRegistry.get(id);
expect(actionTypeModel.id).toEqual(id);
});
});
});
describe('servicenow connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {
username: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.servicenow',
name: 'ServiceNow',
isPreconfigured: false,
config: {
apiUrl: 'https://dev94428.service-now.com/',
},
} as ServiceNowActionConnector;
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
test(`${id}: connector validation succeeds when connector config is valid`, () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionConnector = {
secrets: {
username: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: id,
name: 'ServiceNow',
isPreconfigured: false,
config: {
apiUrl: 'https://dev94428.service-now.com/',
},
} as ServiceNowActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
},
},
},
secrets: {
errors: {
username: [],
password: [],
secrets: {
errors: {
username: [],
password: [],
},
},
},
});
});
});
test('connector validation fails when connector config is not valid', () => {
const actionConnector = ({
secrets: {
username: 'user',
},
id: '.servicenow',
actionTypeId: '.servicenow',
name: 'servicenow',
config: {},
} as unknown) as ServiceNowActionConnector;
test(`${id}: connector validation fails when connector config is not valid`, () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionConnector = ({
secrets: {
username: 'user',
},
id,
actionTypeId: id,
name: 'servicenow',
config: {},
} as unknown) as ServiceNowActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: ['URL is required.'],
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: ['URL is required.'],
},
},
},
secrets: {
errors: {
username: [],
password: ['Password is required.'],
secrets: {
errors: {
username: [],
password: ['Password is required.'],
},
},
},
});
});
});
});
describe('servicenow action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] },
};
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
test(`${id}: action params validation succeeds when action params is valid`, () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionParams = {
subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { ['subActionParams.incident.short_description']: [] },
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { ['subActionParams.incident.short_description']: [] },
});
});
});
test('params validation fails when body is not valid', () => {
const actionParams = {
subActionParams: { incident: { short_description: '' }, comments: [] },
};
test(`${id}: params validation fails when body is not valid`, () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionParams = {
subActionParams: { incident: { short_description: '' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
['subActionParams.incident.short_description']: ['Short description is required.'],
},
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
['subActionParams.incident.short_description']: ['Short description is required.'],
},
});
});
});
});

View file

@ -10,13 +10,14 @@ import {
ActionTypeModel,
ConnectorValidationResult,
} from '../../../../types';
import { connectorConfiguration } from './config';
import { serviceNowITSMConfiguration, serviceNowSIRConfiguration } from './config';
import logo from './logo.svg';
import {
ServiceNowActionConnector,
ServiceNowConfig,
ServiceNowSecrets,
ServiceNowActionParams,
ServiceNowITSMActionParams,
ServiceNowSIRActionParams,
} from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
@ -60,19 +61,21 @@ const validateConnector = (
return validationResult;
};
export function getActionType(): ActionTypeModel<
export function getServiceNowITSMActionType(): ActionTypeModel<
ServiceNowConfig,
ServiceNowSecrets,
ServiceNowActionParams
ServiceNowITSMActionParams
> {
return {
id: connectorConfiguration.id,
id: serviceNowITSMConfiguration.id,
iconClass: logo,
selectMessage: i18n.SERVICENOW_DESC,
actionTypeTitle: connectorConfiguration.name,
selectMessage: serviceNowITSMConfiguration.desc,
actionTypeTitle: serviceNowITSMConfiguration.name,
validateConnector,
actionConnectorFields: lazy(() => import('./servicenow_connectors')),
validateParams: (actionParams: ServiceNowActionParams): GenericValidationResult<unknown> => {
validateParams: (
actionParams: ServiceNowITSMActionParams
): GenericValidationResult<unknown> => {
const errors = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'subActionParams.incident.short_description': new Array<string>(),
@ -89,6 +92,39 @@ export function getActionType(): ActionTypeModel<
}
return validationResult;
},
actionParamsFields: lazy(() => import('./servicenow_params')),
actionParamsFields: lazy(() => import('./servicenow_itsm_params')),
};
}
export function getServiceNowSIRActionType(): ActionTypeModel<
ServiceNowConfig,
ServiceNowSecrets,
ServiceNowSIRActionParams
> {
return {
id: serviceNowSIRConfiguration.id,
iconClass: logo,
selectMessage: serviceNowSIRConfiguration.desc,
actionTypeTitle: serviceNowSIRConfiguration.name,
validateConnector,
actionConnectorFields: lazy(() => import('./servicenow_connectors')),
validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult<unknown> => {
const errors = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'subActionParams.incident.short_description': new Array<string>(),
};
const validationResult = {
errors,
};
if (
actionParams.subActionParams &&
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.short_description?.length
) {
errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED);
}
return validationResult;
},
actionParamsFields: lazy(() => import('./servicenow_sir_params')),
};
}

View file

@ -5,8 +5,18 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import ServiceNowParamsFields from './servicenow_params';
import { act } from '@testing-library/react';
import { ActionConnector } from '../../../../types';
import { useGetChoices } from './use_get_choices';
import ServiceNowITSMParamsFields from './servicenow_itsm_params';
import { Choice } from './types';
jest.mock('./use_get_choices');
jest.mock('../../../../common/lib/kibana');
const useGetChoicesMock = useGetChoices as jest.Mock;
const actionParams = {
subAction: 'pushToService',
subActionParams: {
@ -16,7 +26,6 @@ const actionParams = {
severity: '1',
urgency: '2',
impact: '3',
savedObjectId: '123',
externalId: null,
},
comments: [],
@ -31,6 +40,7 @@ const connector: ActionConnector = {
name: 'Test',
isPreconfigured: false,
};
const editAction = jest.fn();
const defaultProps = {
actionConnector: connector,
@ -40,31 +50,71 @@ const defaultProps = {
index: 0,
messageVariables: [],
};
describe('ServiceNowParamsFields renders', () => {
const useGetChoicesResponse = {
isLoading: false,
choices: ['severity', 'urgency', 'impact']
.map((element) => [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element,
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element,
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element,
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element,
},
])
.flat(),
};
describe('ServiceNowITSMParamsFields renders', () => {
let onChoices = (choices: Choice[]) => {};
beforeEach(() => {
jest.clearAllMocks();
useGetChoicesMock.mockImplementation((args) => {
onChoices = args.onSuccess;
return useGetChoicesResponse;
});
});
test('all params fields is rendered', () => {
const wrapper = mount(<ServiceNowParamsFields {...defaultProps} />);
expect(wrapper.find('[data-test-subj="urgencySelect"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual(
'1'
);
expect(wrapper.find('[data-test-subj="impactSelect"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="short_descriptionInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy();
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy();
});
test('If short_description has errors, form row is invalid', () => {
const newProps = {
...defaultProps,
// eslint-disable-next-line @typescript-eslint/naming-convention
errors: { 'subActionParams.incident.short_description': ['error'] },
};
const wrapper = mount(<ServiceNowParamsFields {...newProps} />);
const wrapper = mount(<ServiceNowITSMParamsFields {...newProps} />);
const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first();
expect(title.prop('isInvalid')).toBeTruthy();
});
test('When subActionParams is undefined, set to default', () => {
const { subActionParams, ...newParams } = actionParams;
@ -72,12 +122,13 @@ describe('ServiceNowParamsFields renders', () => {
...defaultProps,
actionParams: newParams,
};
mount(<ServiceNowParamsFields {...newProps} />);
mount(<ServiceNowITSMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {},
comments: [],
});
});
test('When subAction is undefined, set to default', () => {
const { subAction, ...newParams } = actionParams;
@ -85,11 +136,12 @@ describe('ServiceNowParamsFields renders', () => {
...defaultProps,
actionParams: newParams,
};
mount(<ServiceNowParamsFields {...newProps} />);
mount(<ServiceNowITSMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual('pushToService');
});
test('Resets fields when connector changes', () => {
const wrapper = mount(<ServiceNowParamsFields {...defaultProps} />);
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
expect(editAction.mock.calls.length).toEqual(0);
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction.mock.calls.length).toEqual(1);
@ -98,6 +150,52 @@ describe('ServiceNowParamsFields renders', () => {
comments: [],
});
});
test('it transforms the urgencies to options correctly', async () => {
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
onChoices(useGetChoicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="urgencySelect"]').first().prop('options')).toEqual([
{ value: '1', text: '1 - Critical' },
{ value: '2', text: '2 - High' },
{ value: '3', text: '3 - Moderate' },
{ value: '4', text: '4 - Low' },
]);
});
test('it transforms the severities to options correctly', async () => {
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
onChoices(useGetChoicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([
{ value: '1', text: '1 - Critical' },
{ value: '2', text: '2 - High' },
{ value: '3', text: '3 - Moderate' },
{ value: '4', text: '4 - Low' },
]);
});
test('it transforms the impacts to options correctly', async () => {
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
onChoices(useGetChoicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="impactSelect"]').first().prop('options')).toEqual([
{ value: '1', text: '1 - Critical' },
{ value: '2', text: '2 - High' },
{ value: '3', text: '3 - Moderate' },
{ value: '4', text: '4 - Low' },
]);
});
describe('UI updates', () => {
const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent<HTMLSelectElement>;
const simpleFields = [
@ -107,22 +205,25 @@ describe('ServiceNowParamsFields renders', () => {
{ dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' },
{ dataTestSubj: '[data-test-subj="impactSelect"]', key: 'impact' },
];
simpleFields.forEach((field) =>
test(`${field.key} update triggers editAction :D`, () => {
const wrapper = mount(<ServiceNowParamsFields {...defaultProps} />);
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
const theField = wrapper.find(field.dataTestSubj).first();
theField.prop('onChange')!(changeEvent);
expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value);
})
);
test('A comment triggers editAction', () => {
const wrapper = mount(<ServiceNowParamsFields {...defaultProps} />);
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]');
expect(comments.simulate('change', changeEvent));
expect(editAction.mock.calls[0][1].comments.length).toEqual(1);
});
test('An empty comment does not trigger editAction', () => {
const wrapper = mount(<ServiceNowParamsFields {...defaultProps} />);
const wrapper = mount(<ServiceNowITSMParamsFields {...defaultProps} />);
const emptyComment = { target: { value: '' } };
const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea');
expect(comments.simulate('change', emptyComment));

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiFormRow,
EuiSelect,
@ -14,38 +13,29 @@ import {
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionParamsProps } from '../../../../types';
import { ServiceNowActionParams } from './types';
import { ServiceNowITSMActionParams, Choice, Options } from './types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import { useGetChoices } from './use_get_choices';
import * as i18n from './translations';
const selectOptions = [
{
value: '1',
text: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectHighOptionLabel',
{ defaultMessage: 'High' }
),
},
{
value: '2',
text: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectMediumOptionLabel',
{ defaultMessage: 'Medium' }
),
},
{
value: '3',
text: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectLawOptionLabel',
{ defaultMessage: 'Low' }
),
},
];
const useGetChoicesFields = ['urgency', 'severity', 'impact'];
const defaultOptions: Options = {
urgency: [],
severity: [],
impact: [],
};
const ServiceNowParamsFields: React.FunctionComponent<
ActionParamsProps<ServiceNowActionParams>
ActionParamsProps<ServiceNowITSMActionParams>
> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const actionConnectorRef = useRef(actionConnector?.id ?? '');
const { incident, comments } = useMemo(
() =>
@ -53,10 +43,12 @@ const ServiceNowParamsFields: React.FunctionComponent<
(({
incident: {},
comments: [],
} as unknown) as ServiceNowActionParams['subActionParams']),
} as unknown) as ServiceNowITSMActionParams['subActionParams']),
[actionParams.subActionParams]
);
const [options, setOptions] = useState<Options>(defaultOptions);
const editSubActionProperty = useCallback(
(key: string, value: any) => {
const newProps =
@ -80,6 +72,28 @@ const ServiceNowParamsFields: React.FunctionComponent<
[editSubActionProperty]
);
const onChoicesSuccess = (choices: Choice[]) =>
setOptions(
choices.reduce(
(acc, choice) => ({
...acc,
[choice.element]: [
...(acc[choice.element] != null ? acc[choice.element] : []),
{ value: choice.value, text: choice.label },
],
}),
defaultOptions
)
);
const { isLoading: isLoadingChoices } = useGetChoices({
http,
toastNotifications: toasts,
actionConnector,
fields: useGetChoicesFields,
onSuccess: onChoicesSuccess,
});
useEffect(() => {
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
actionConnectorRef.current = actionConnector.id;
@ -94,6 +108,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
@ -114,64 +129,47 @@ const ServiceNowParamsFields: React.FunctionComponent<
return (
<Fragment>
<EuiTitle size="s">
<h3>
{i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.title',
{ defaultMessage: 'Incident' }
)}
</h3>
<h3>{i18n.INCIDENT}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.urgencySelectFieldLabel',
{ defaultMessage: 'Urgency' }
)}
>
<EuiFormRow fullWidth label={i18n.URGENCY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
hasNoInitialSelection
options={selectOptions}
value={incident.urgency ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={options.urgency}
value={incident.urgency ?? ''}
onChange={(e) => editSubActionProperty('urgency', e.target.value)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.severitySelectFieldLabel',
{ defaultMessage: 'Severity' }
)}
>
<EuiFormRow fullWidth label={i18n.SEVERITY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
hasNoInitialSelection
options={selectOptions}
value={incident.severity ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={options.severity}
value={incident.severity ?? ''}
onChange={(e) => editSubActionProperty('severity', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.impactSelectFieldLabel',
{ defaultMessage: 'Impact' }
)}
>
<EuiFormRow fullWidth label={i18n.IMPACT_LABEL}>
<EuiSelect
fullWidth
data-test-subj="impactSelect"
hasNoInitialSelection
options={selectOptions}
value={incident.impact ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={options.impact}
value={incident.impact ?? ''}
onChange={(e) => editSubActionProperty('impact', e.target.value)}
/>
</EuiFormRow>
@ -185,10 +183,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel',
{ defaultMessage: 'Short description (required)' }
)}
label={i18n.SHORT_DESCRIPTION_LABEL}
>
<TextFieldWithMessageVariables
index={index}
@ -205,10 +200,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={incident.description ?? undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.descriptionTextAreaFieldLabel',
{ defaultMessage: 'Description' }
)}
label={i18n.DESCRIPTION_LABEL}
/>
<TextAreaWithMessageVariables
index={index}
@ -216,10 +208,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel',
{ defaultMessage: 'Additional comments' }
)}
label={i18n.COMMENTS_LABEL}
/>
</Fragment>
);

View file

@ -0,0 +1,311 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from '@testing-library/react';
import { ActionConnector } from '../../../../types';
import { useGetChoices } from './use_get_choices';
import ServiceNowSIRParamsFields from './servicenow_sir_params';
import { Choice } from './types';
jest.mock('./use_get_choices');
jest.mock('../../../../common/lib/kibana');
const useGetChoicesMock = useGetChoices as jest.Mock;
const actionParams = {
subAction: 'pushToService',
subActionParams: {
incident: {
short_description: 'sn title',
description: 'some description',
category: 'Denial of Service',
dest_ip: '192.168.1.1',
source_ip: '192.168.1.2',
malware_hash: '098f6bcd4621d373cade4e832627b4f6',
malware_url: 'https://attack.com',
priority: '1',
subcategory: '20',
externalId: null,
},
comments: [],
},
};
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: [
{
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',
},
{
dependent_value: 'Denial of Service',
label: 'Inbound or outbound',
value: '12',
element: 'subcategory',
},
{
dependent_value: 'Denial of Service',
label: 'Single or distributed (DoS or DDoS)',
value: '26',
element: 'subcategory',
},
{
dependent_value: 'Denial of Service',
label: 'Inbound DDos',
value: 'inbound_ddos',
element: 'subcategory',
},
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element: 'priority',
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element: 'priority',
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element: 'priority',
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element: 'priority',
},
{
dependent_value: '',
label: '5 - Planning',
value: '5',
element: 'priority',
},
],
};
describe('ServiceNowSIRParamsFields renders', () => {
let onChoicesSuccess = (choices: Choice[]) => {};
beforeEach(() => {
jest.clearAllMocks();
useGetChoicesMock.mockImplementation((args) => {
onChoicesSuccess = args.onSuccess;
return choicesResponse;
});
});
test('all params fields is rendered', () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="source_ipInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="dest_ipInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="malware_urlInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="malware_hashInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy();
});
test('If short_description has errors, form row is invalid', () => {
const newProps = {
...defaultProps,
// eslint-disable-next-line @typescript-eslint/naming-convention
errors: { 'subActionParams.incident.short_description': ['error'] },
};
const wrapper = mount(<ServiceNowSIRParamsFields {...newProps} />);
const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first();
expect(title.prop('isInvalid')).toBeTruthy();
});
test('When subActionParams is undefined, set to default', () => {
const { subActionParams, ...newParams } = actionParams;
const newProps = {
...defaultProps,
actionParams: newParams,
};
mount(<ServiceNowSIRParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {},
comments: [],
});
});
test('When subAction is undefined, set to default', () => {
const { subAction, ...newParams } = actionParams;
const newProps = {
...defaultProps,
actionParams: newParams,
};
mount(<ServiceNowSIRParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual('pushToService');
});
test('Resets fields when connector changes', () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...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({
incident: {},
comments: [],
});
});
test('it transforms the categories to options correctly', async () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
act(() => {
onChoicesSuccess(choicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([
{ value: 'Priviledge Escalation', text: 'Priviledge Escalation' },
{
value: 'Criminal activity/investigation',
text: 'Criminal activity/investigation',
},
{ value: 'Denial of Service', text: 'Denial of Service' },
]);
});
test('it transforms the subcategories to options correctly', async () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
act(() => {
onChoicesSuccess(choicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([
{
text: 'Inbound or outbound',
value: '12',
},
{
text: 'Single or distributed (DoS or DDoS)',
value: '26',
},
{
text: 'Inbound DDos',
value: 'inbound_ddos',
},
]);
});
test('it transforms the priorities to options correctly', async () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
act(() => {
onChoicesSuccess(choicesResponse.choices);
});
wrapper.update();
expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('options')).toEqual([
{
text: '1 - Critical',
value: '1',
},
{
text: '2 - High',
value: '2',
},
{
text: '3 - Moderate',
value: '3',
},
{
text: '4 - Low',
value: '4',
},
{
text: '5 - Planning',
value: '5',
},
]);
});
describe('UI updates', () => {
const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent<HTMLSelectElement>;
const simpleFields = [
{ dataTestSubj: 'input[data-test-subj="short_descriptionInput"]', key: 'short_description' },
{ dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' },
{ dataTestSubj: '[data-test-subj="source_ipInput"]', key: 'source_ip' },
{ dataTestSubj: '[data-test-subj="dest_ipInput"]', key: 'dest_ip' },
{ dataTestSubj: '[data-test-subj="malware_urlInput"]', key: 'malware_url' },
{ dataTestSubj: '[data-test-subj="malware_hashInput"]', key: 'malware_hash' },
{ dataTestSubj: '[data-test-subj="prioritySelect"]', key: 'priority' },
{ dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' },
{ dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' },
];
simpleFields.forEach((field) =>
test(`${field.key} update triggers editAction :D`, () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
const theField = wrapper.find(field.dataTestSubj).first();
theField.prop('onChange')!(changeEvent);
expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value);
})
);
test('A comment triggers editAction', () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]');
expect(comments.simulate('change', changeEvent));
expect(editAction.mock.calls[0][1].comments.length).toEqual(1);
});
test('An empty comment does not trigger editAction', () => {
const wrapper = mount(<ServiceNowSIRParamsFields {...defaultProps} />);
const emptyComment = { target: { value: '' } };
const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea');
expect(comments.simulate('change', emptyComment));
expect(editAction.mock.calls.length).toEqual(0);
});
});
});

View file

@ -0,0 +1,295 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiFormRow,
EuiSelect,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiSelectOption,
} 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 { useGetChoices } from './use_get_choices';
import { ServiceNowSIRActionParams, Fields, Choice } from './types';
const useGetChoicesFields = ['category', 'subcategory', 'priority'];
const defaultFields: Fields = {
category: [],
subcategory: [],
priority: [],
};
const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));
const ServiceNowSIRParamsFields: React.FunctionComponent<
ActionParamsProps<ServiceNowSIRActionParams>
> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const actionConnectorRef = useRef(actionConnector?.id ?? '');
const { incident, comments } = useMemo(
() =>
actionParams.subActionParams ??
(({
incident: {},
comments: [],
} as unknown) as ServiceNowSIRActionParams['subActionParams']),
[actionParams.subActionParams]
);
const [choices, setChoices] = useState<Fields>(defaultFields);
const editSubActionProperty = useCallback(
(key: string, value: any) => {
const newProps =
key !== 'comments'
? {
incident: { ...incident, [key]: value },
comments,
}
: { incident, [key]: value };
editAction('subActionParams', newProps, index);
},
[comments, editAction, incident, index]
);
const editComment = useCallback(
(key, value) => {
if (value.length > 0) {
editSubActionProperty(key, [{ commentId: '1', comment: value }]);
}
},
[editSubActionProperty]
);
const onChoicesSuccess = useCallback((values: Choice[]) => {
setChoices(
values.reduce(
(acc, value) => ({
...acc,
[value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value],
}),
defaultFields
)
);
}, []);
const { isLoading: isLoadingChoices } = useGetChoices({
http,
toastNotifications: toasts,
actionConnector,
// Not having a memoized fields variable will cause infinitive API calls.
fields: useGetChoicesFields,
onSuccess: onChoicesSuccess,
});
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
const priorityOptions = useMemo(() => choicesToEuiOptions(choices.priority), [choices.priority]);
const subcategoryOptions = useMemo(
() =>
choicesToEuiOptions(
choices.subcategory.filter(
(subcategory) => subcategory.dependent_value === incident.category
)
),
[choices.subcategory, incident.category]
);
useEffect(() => {
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
actionConnectorRef.current = actionConnector.id;
editAction(
'subActionParams',
{
incident: {},
comments: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!actionParams.subActionParams) {
editAction(
'subActionParams',
{
incident: {},
comments: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionParams]);
return (
<Fragment>
<EuiTitle size="s">
<h3>{i18n.INCIDENT}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.SHORT_DESCRIPTION_LABEL}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.SOURCE_IP_LABEL}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'source_ip'}
inputTargetValue={incident?.source_ip ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.DEST_IP_LABEL}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'dest_ip'}
inputTargetValue={incident?.dest_ip ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.MALWARE_URL_LABEL}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'malware_url'}
inputTargetValue={incident?.malware_url ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.MALWARE_HASH_LABEL}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'malware_hash'}
inputTargetValue={incident?.malware_hash ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.PRIORITY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="prioritySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={priorityOptions}
value={incident.priority ?? undefined}
onChange={(e) => {
editAction(
'subActionParams',
{
incident: { ...incident, priority: e.target.value },
comments,
},
index
);
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={categoryOptions}
value={incident.category ?? undefined}
onChange={(e) => {
editAction(
'subActionParams',
{
incident: { ...incident, category: e.target.value, subcategory: null },
comments,
},
index
);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={incident.subcategory ?? ''}
onChange={(e) => editSubActionProperty('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={incident.description ?? undefined}
label={i18n.DESCRIPTION_LABEL}
/>
<TextAreaWithMessageVariables
index={index}
editAction={editComment}
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.COMMENTS_LABEL}
/>
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { ServiceNowSIRParamsFields as default };

View file

@ -6,17 +6,31 @@
import { i18n } from '@kbn/i18n';
export const SERVICENOW_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.selectMessageText',
export const SERVICENOW_ITSM_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText',
{
defaultMessage: 'Create an incident in ServiceNow.',
defaultMessage: 'Create an incident in ServiceNow ITSM.',
}
);
export const SERVICENOW_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.actionTypeTitle',
export const SERVICENOW_SIR_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText',
{
defaultMessage: 'ServiceNow',
defaultMessage: 'Create an incident in ServiceNow SIR.',
}
);
export const SERVICENOW_ITSM_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle',
{
defaultMessage: 'ServiceNow ITSM',
}
);
export const SERVICENOW_SIR_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle',
{
defaultMessage: 'ServiceNow SIR',
}
);
@ -98,65 +112,114 @@ export const PASSWORD_REQUIRED = i18n.translate(
}
);
export const API_TOKEN_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiTokenTextFieldLabel',
{
defaultMessage: 'Api token',
}
);
export const API_TOKEN_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredApiTokenTextField',
{
defaultMessage: 'Api token is required.',
}
);
export const EMAIL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.emailTextFieldLabel',
{
defaultMessage: 'Email',
}
);
export const EMAIL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredEmailTextField',
{
defaultMessage: 'Email is required.',
}
);
export const MAPPING_FIELD_SHORT_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldShortDescription',
{
defaultMessage: 'Short Description',
}
);
export const MAPPING_FIELD_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldDescription',
{
defaultMessage: 'Description',
}
);
export const MAPPING_FIELD_COMMENTS = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.mappingFieldComments',
{
defaultMessage: 'Comments',
}
);
export const DESCRIPTION_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField',
{
defaultMessage: 'Description is required.',
}
);
export const TITLE_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredShortDescTextField',
{
defaultMessage: 'Short description is required.',
}
);
export const SOURCE_IP_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle',
{
defaultMessage: 'Source IP',
}
);
export const DEST_IP_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle',
{
defaultMessage: 'Destination IP',
}
);
export const INCIDENT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title',
{
defaultMessage: 'Incident',
}
);
export const SHORT_DESCRIPTION_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel',
{
defaultMessage: 'Short description (required)',
}
);
export const DESCRIPTION_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.descriptionTextAreaFieldLabel',
{
defaultMessage: 'Description',
}
);
export const COMMENTS_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.commentsTextAreaFieldLabel',
{
defaultMessage: 'Additional comments',
}
);
export const MALWARE_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle',
{
defaultMessage: 'Malware URL',
}
);
export const MALWARE_HASH_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle',
{
defaultMessage: 'Malware hash',
}
);
export const CHOICES_API_ERROR = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage',
{
defaultMessage: 'Unable to get choices',
}
);
export const CATEGORY_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.categoryTitle',
{
defaultMessage: 'Category',
}
);
export const SUBCATEGORY_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.subcategoryTitle',
{
defaultMessage: 'Subcategory',
}
);
export const URGENCY_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel',
{
defaultMessage: 'Urgency',
}
);
export const SEVERITY_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectFieldLabel',
{
defaultMessage: 'Severity',
}
);
export const IMPACT_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.impactSelectFieldLabel',
{
defaultMessage: 'Impact',
}
);
export const PRIORITY_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.prioritySelectFieldLabel',
{
defaultMessage: 'Priority',
}
);

View file

@ -4,18 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiSelectOption } from '@elastic/eui';
import { UserConfiguredActionConnector } from '../../../../types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ExecutorSubActionPushParams } from '../../../../../../actions/server/builtin_action_types/servicenow/types';
import {
ExecutorSubActionPushParamsITSM,
ExecutorSubActionPushParamsSIR,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../../actions/server/builtin_action_types/servicenow/types';
export type ServiceNowActionConnector = UserConfiguredActionConnector<
ServiceNowConfig,
ServiceNowSecrets
>;
export interface ServiceNowActionParams {
export interface ServiceNowITSMActionParams {
subAction: string;
subActionParams: ExecutorSubActionPushParams;
subActionParams: ExecutorSubActionPushParamsITSM;
}
export interface ServiceNowSIRActionParams {
subAction: string;
subActionParams: ExecutorSubActionPushParamsSIR;
}
export interface ServiceNowConfig {
@ -26,3 +35,13 @@ export interface ServiceNowSecrets {
username: string;
password: string;
}
export interface Choice {
value: string;
label: string;
element: string;
dependent_value: string;
}
export type Fields = Record<string, Choice[]>;
export type Options = Record<string, EuiSelectOption[]>;

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionConnector } from '../../../../types';
import { useGetChoices, UseGetChoices, UseGetChoicesProps } from './use_get_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 onSuccess = jest.fn();
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',
},
];
describe('useGetChoices', () => {
const { services } = useKibanaMock();
getChoicesMock.mockResolvedValue({
data: getChoicesResponse,
});
beforeEach(() => {
jest.clearAllMocks();
});
const fields = ['priority'];
it('init', async () => {
const { result, waitForNextUpdate } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
useGetChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
onSuccess,
})
);
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
choices: getChoicesResponse,
});
});
it('returns an empty array when connector is not presented', async () => {
const { result } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
useGetChoices({
http: services.http,
actionConnector: undefined,
toastNotifications: services.notifications.toasts,
fields,
onSuccess,
})
);
expect(result.current).toEqual({
isLoading: false,
choices: [],
});
});
it('it calls onSuccess', async () => {
const { waitForNextUpdate } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
useGetChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
onSuccess,
})
);
await waitForNextUpdate();
expect(onSuccess).toHaveBeenCalledWith(getChoicesResponse);
});
it('it displays an error when service fails', async () => {
getChoicesMock.mockResolvedValue({
status: 'error',
serviceMessage: 'An error occurred',
});
const { waitForNextUpdate } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
useGetChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
onSuccess,
})
);
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<UseGetChoicesProps, UseGetChoices>(() =>
useGetChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
onSuccess,
})
);
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
text: 'An error occurred',
title: 'Unable to get choices',
});
});
});

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { getChoices } from './api';
import { Choice } from './types';
import * as i18n from './translations';
export interface UseGetChoicesProps {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
fields: string[];
onSuccess?: (choices: Choice[]) => void;
}
export interface UseGetChoices {
choices: Choice[];
isLoading: boolean;
}
export const useGetChoices = ({
http,
actionConnector,
toastNotifications,
fields,
onSuccess,
}: UseGetChoicesProps): UseGetChoices => {
const [isLoading, setIsLoading] = useState(false);
const [choices, setChoices] = useState<Choice[]>([]);
const didCancel = useRef(false);
const abortCtrl = useRef(new AbortController());
const fetchData = useCallback(async () => {
if (!actionConnector) {
setIsLoading(false);
return;
}
try {
didCancel.current = false;
abortCtrl.current.abort();
abortCtrl.current = new AbortController();
setIsLoading(true);
const res = await getChoices({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
fields,
});
if (!didCancel.current) {
setIsLoading(false);
setChoices(res.data ?? []);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.CHOICES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
} else if (onSuccess) {
onSuccess(res.data ?? []);
}
}
} catch (error) {
if (!didCancel.current) {
setIsLoading(false);
toastNotifications.addDanger({
title: i18n.CHOICES_API_ERROR,
text: error.message,
});
}
}
}, [actionConnector, http, fields, onSuccess, toastNotifications]);
useEffect(() => {
fetchData();
return () => {
didCancel.current = true;
abortCtrl.current.abort();
setIsLoading(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
choices,
isLoading,
};
};

View file

@ -34,7 +34,11 @@ import { ActionTypeForm, ActionTypeFormProps } from './action_type_form';
import { AddConnectorInline } from './connector_add_inline';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants';
import {
VIEW_LICENSE_OPTIONS_LINK,
DEFAULT_HIDDEN_ACTION_TYPES,
DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES,
} from '../../../common/constants';
import { ActionGroup, AlertActionParam } from '../../../../../alerts/common';
import { useKibana } from '../../../common/lib/kibana';
import { DefaultActionParamsGetter } from '../../lib/get_defaults_for_action_params';
@ -230,9 +234,15 @@ export const ActionForm = ({
.list()
/**
* TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502.
* TODO: Need to decide about ServiceNow SIR connector.
* If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not.
*/
.filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id))
.filter(
({ id }) =>
actionTypes ??
(!DEFAULT_HIDDEN_ACTION_TYPES.includes(id) &&
!DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES.includes(id))
)
.filter((item) => actionTypesIndex[item.id])
.filter((item) => !!item.actionParamsFields)
.sort((a, b) =>

View file

@ -11,3 +11,5 @@ export { builtInGroupByTypes } from './group_by_types';
export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';
// TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502.
export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case'];
// Action types included in this array will be hidden only from the alert's action type node list
export const DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES = ['.servicenow-sir'];

View file

@ -10,6 +10,6 @@ export * from './index_controls';
export * from './lib';
export * from './types';
export { connectorConfiguration as ServiceNowConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config';
export { serviceNowITSMConfiguration as ServiceNowITSMConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config';
export { connectorConfiguration as JiraConnectorConfiguration } from '../application/components/builtin_action_types/jira/config';
export { connectorConfiguration as ResilientConnectorConfiguration } from '../application/components/builtin_action_types/resilient/config';

View file

@ -9,7 +9,7 @@ import {
JiraActionTypeId,
PagerDutyActionTypeId,
ServerLogActionTypeId,
ServiceNowActionTypeId,
ServiceNowITSMActionTypeId as ServiceNowActionTypeId,
SlackActionTypeId,
TeamsActionTypeId,
WebhookActionTypeId,

View file

@ -38,6 +38,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] {
getExternalServiceSimulatorPath(service)
);
allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`);
allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`);
allPaths.push(
`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary`
);

View file

@ -127,6 +127,51 @@ export function initPlugin(router: IRouter, path: string) {
});
}
);
router.get(
{
path: `${path}/api/now/v2/table/sys_choice`,
options: {
authRequired: false,
},
validate: {},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
return jsonResponse(res, 200, {
result: [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
},
{
dependent_value: '',
label: '2 - High',
value: '2',
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
},
{
dependent_value: '',
label: '5 - Planning',
value: '5',
},
],
});
}
);
}
function jsonResponse(res: KibanaResponseFactory, code: number, object?: Record<string, unknown>) {

View file

@ -216,7 +216,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
// Cannot destructure property 'value' of 'undefined' as it is undefined.
//
// The error seems to come from the exact same place in the code based on the
// exact same circomstances:
// exact same circumstances:
//
// https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28
//
@ -247,7 +247,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]',
});
});
});
@ -265,7 +265,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]',
});
});
});
@ -288,7 +288,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]',
});
});
});
@ -315,7 +315,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]',
});
});
});
@ -342,10 +342,33 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]',
});
});
});
describe('getChoices', () => {
it('should fail when field is not provided', async () => {
await supertest
.post(`/api/actions/action/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'getChoices',
subActionParams: {},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
actionId: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]',
});
});
});
});
});
describe('Execution', () => {
@ -376,6 +399,54 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
},
});
});
describe('getChoices', () => {
it('should get choices', async () => {
const { body: result } = await supertest
.post(`/api/actions/action/${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',
actionId: 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',
},
],
});
});
});
});
after(() => {