From 066f9b01ece20fb52a4787286afe7031648a565b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 13 Jul 2021 16:54:35 +0300 Subject: [PATCH 01/92] POC --- .../builtin_action_types/servicenow/api.ts | 2 +- .../servicenow/service.ts | 78 +++++++++++++++---- .../builtin_action_types/servicenow/types.ts | 37 ++++++++- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 4120c07c32303..3254ed09251d1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -41,7 +41,7 @@ const pushToServiceHandler = async ({ res = await externalService.createIncident({ incident: { ...incident, - caller_id: secrets.username, + opened_by: secrets.username, }, }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 07ed9edc94d39..29ec64e8d8f88 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -7,7 +7,14 @@ import axios, { AxiosResponse } from 'axios'; -import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types'; +import { + ExternalServiceCredentials, + ExternalService, + ExternalServiceParams, + ImportSetApiResponse, + ImportSetApiResponseError, + ServiceNowIncident, +} from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; @@ -21,6 +28,14 @@ import { ActionsConfigurationUtilities } from '../../actions_config'; const API_VERSION = 'v2'; const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`; +const IMPORTATION_SET_TABLE = 'x_463134_elastic_import_set_web_service'; +const FIELD_PREFIX = 'u_'; + +const prefixFields = (incident: ExternalServiceParams['incident']) => + Object.entries(incident).reduce( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} + ); export const createExternalService = ( table: string, @@ -36,6 +51,7 @@ export const createExternalService = ( } const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${IMPORTATION_SET_TABLE}`; 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`; @@ -57,7 +73,7 @@ export const createExternalService = ( }; const checkInstance = (res: AxiosResponse) => { - if (res.status === 200 && res.data.result == null) { + if ((res.status >= 200 || res.status < 400) && res.data.result == null) { throw new Error( `There is an issue with your Service Now Instance. Please check ${ res.request?.connection?.servername ?? '' @@ -75,7 +91,24 @@ export const createExternalService = ( return error != null ? `${error?.message}: ${error?.detail}` : ''; }; - const getIncident = async (id: string) => { + const isImportSetApiResponseAnError = ( + data: ImportSetApiResponse['result'][0] + ): data is ImportSetApiResponseError['result'][0] => data.status === 'error'; + + const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => { + if (res.result.length === 0) { + throw new Error('Unexpected result'); + } + + const data = res.result[0]; + + // Create ResponseError message? + if (isImportSetApiResponseAnError(data)) { + throw new Error(data.error_message); + } + }; + + const getIncident = async (id: string): Promise => { try { const res = await request({ axios: axiosInstance, @@ -83,7 +116,9 @@ export const createExternalService = ( logger, configurationUtilities, }); + checkInstance(res); + return { ...res.data.result }; } catch (error) { throw new Error( @@ -124,18 +159,22 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}`, + url: importSetTableUrl, logger, method: 'post', - data: { ...(incident as Record) }, + data: prefixFields(incident), configurationUtilities, }); + checkInstance(res); + throwIfImportSetApiResponseIsAnError(res.data); + const insertedIncident = await getIncident(res.data.result[0].sys_id); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: insertedIncident.number, + id: insertedIncident.sys_id, + pushedDate: new Date(addTimeZoneToDate(insertedIncident.sys_created_on)).toISOString(), + url: getIncidentViewURL(insertedIncident.sys_id), }; } catch (error) { throw new Error( @@ -151,19 +190,24 @@ export const createExternalService = ( const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { try { - const res = await patch({ + const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${incidentId}`, + url: importSetTableUrl, + method: 'post', logger, - data: { ...(incident as Record) }, + data: { ...prefixFields(incident), u_incident_id: incidentId }, configurationUtilities, }); + checkInstance(res); + throwIfImportSetApiResponseIsAnError(res.data); + const insertedIncident = await getIncident(res.data.result[0].sys_id); + return { - title: res.data.result.number, - id: res.data.result.sys_id, - pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - url: getIncidentViewURL(res.data.result.sys_id), + title: insertedIncident.number, + id: insertedIncident.sys_id, + pushedDate: new Date(addTimeZoneToDate(insertedIncident.sys_updated_on)).toISOString(), + url: getIncidentViewURL(insertedIncident.sys_id), }; } catch (error) { throw new Error( @@ -185,7 +229,9 @@ export const createExternalService = ( logger, configurationUtilities, }); + checkInstance(res); + return res.data.result.length > 0 ? res.data.result : []; } catch (error) { throw new Error( diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 50631cf289a73..0aad77f7e87b5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -158,12 +158,20 @@ export interface GetChoicesHandlerArgs { params: ExecutorSubActionGetChoicesParams; } +export interface ServiceNowIncident { + sys_id: string; + number: string; + sys_created_on: string; + sys_updated_on: string; + [x: string]: unknown; +} + export interface ExternalServiceApi { getChoices: (args: GetChoicesHandlerArgs) => Promise; getFields: (args: GetCommonFieldsHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; pushToService: (args: PushToServiceApiHandlerArgs) => Promise; - getIncident: (args: GetIncidentApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; } export interface ExternalServiceCommentResponse { @@ -180,3 +188,30 @@ export interface ResponseError { }>; status: TypeNullOrUndefined; } + +export interface ImportSetApiResponseSuccess { + import_set: string; + staging_table: string; + result: Array<{ + display_name: string; + display_value: string; + record_link: string; + status: string; + sys_id: string; + table: string; + transform_map: string; + }>; +} + +export interface ImportSetApiResponseError { + import_set: string; + staging_table: string; + result: Array<{ + error_message: string; + status_message: string; + status: string; + transform_map: string; + }>; +} + +export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; From 0cd002794de8dd2817d8b6ce12792f3ef85c197e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 12:26:33 +0300 Subject: [PATCH 02/92] Before and after saving connector callbacks --- .../action_connector_form/action_connector_form.tsx | 5 +++++ .../action_connector_form/connector_add_flyout.tsx | 9 ++++++++- .../action_connector_form/connector_edit_flyout.tsx | 8 ++++++++ x-pack/plugins/triggers_actions_ui/public/types.ts | 6 ++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index f61a0f8f52904..c19926b355b31 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -24,6 +24,8 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, ActionTypeModel, + ActionConnector, + ActionConnectorFieldsProps, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useKibana } from '../../../common/lib/kibana'; @@ -89,6 +91,7 @@ interface ActionConnectorProps< serverError?: { body: { message: string; error: string }; }; + setCallbacks: ActionConnectorFieldsProps['setCallbacks']; } export const ActionConnectorForm = ({ @@ -99,6 +102,7 @@ export const ActionConnectorForm = ({ errors, actionTypeRegistry, consumer, + setCallbacks, }: ActionConnectorProps) => { const { docLinks, @@ -237,6 +241,7 @@ export const ActionConnectorForm = ({ editActionConfig={setActionConfigProperty} editActionSecrets={setActionSecretsProperty} consumer={consumer} + setCallbacks={setCallbacks} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 1a3a186d891cc..a18566baa3683 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useReducer, useEffect } from 'react'; +import React, { useCallback, useState, useReducer, useEffect, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -121,6 +121,10 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ }; const [isSaving, setIsSaving] = useState(false); + const [callbacks, setCallbacks] = useState void; + afterActionConnectorSave?: () => void; + }>(null); const closeFlyout = useCallback(() => { onClose(); @@ -155,6 +159,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ errors={errors.connectorErrors} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} /> ); @@ -200,7 +205,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ return; } setIsSaving(true); + await callbacks?.beforeActionConnectorSave?.(); const savedAction = await onActionConnectorSave(); + await callbacks?.afterActionConnectorSave?.(); setIsSaving(false); if (savedAction) { closeFlyout(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 25c8103f0c8dc..67c9b00fe1aa0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -138,6 +138,11 @@ const ConnectorEditFlyout = ({ [testExecutionResult] ); + const [callbacks, setCallbacks] = useState void; + afterActionConnectorSave?: () => void; + }>(null); + const closeFlyout = useCallback(() => { setConnector(getConnectorWithoutSecrets()); setHasChanges(false); @@ -249,7 +254,9 @@ const ConnectorEditFlyout = ({ return; } setIsSaving(true); + await callbacks?.beforeActionConnectorSave?.(); const savedAction = await onActionConnectorSave(); + await callbacks?.afterActionConnectorSave?.(); setIsSaving(false); if (savedAction) { setHasChanges(false); @@ -313,6 +320,7 @@ const ConnectorEditFlyout = ({ }} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} /> {isLoading ? ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index ae4fd5152794f..7bf5dac93065d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -80,6 +80,12 @@ export interface ActionConnectorFieldsProps { errors: IErrorObject; readOnly: boolean; consumer?: string; + setCallbacks: React.Dispatch< + React.SetStateAction<{ + beforeActionConnectorSave?: (() => void) | undefined; + afterActionConnectorSave?: ((connector: ActionConnector) => void) | undefined; + } | null> + >; } export enum AlertFlyoutCloseReason { From ed3f37f44442a4b44d5d654e8569a1ff68b1fb6a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 12:31:03 +0300 Subject: [PATCH 03/92] Draft callbacks on SN --- .../servicenow/servicenow_connectors.tsx | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 29a6bca4b16ab..1b73ab4d91b07 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { EuiFieldText, @@ -26,10 +26,11 @@ import { ServiceNowActionConnector } from './types'; import { useKibana } from '../../../../common/lib/kibana'; import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; -const ServiceNowConnectorFields: React.FC> = - ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { - const { docLinks } = useKibana().services; - const { apiUrl } = action.config; +const ServiceNowConnectorFields: React.FC< + ActionConnectorFieldsProps +> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, setCallbacks }) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; const isApiUrlInvalid: boolean = errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; @@ -46,16 +47,43 @@ const ServiceNowConnectorFields: React.FC editActionSecrets(key, value), - [editActionSecrets] - ); - return ( - <> - - - editActionSecrets(key, value), + [editActionSecrets] + ); + + const beforeActionConnectorSave = useCallback(() => { + // TODO: Validate instance + }, []); + + const afterActionConnectorSave = useCallback(() => { + // TODO: Implement + }, []); + + // Callbacks are being set only once mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => setCallbacks({ beforeActionConnectorSave, afterActionConnectorSave }), []); + + return ( + <> + + + + + + } + > + Date: Mon, 19 Jul 2021 13:10:08 +0300 Subject: [PATCH 04/92] Migrate legacy connectors --- .../builtin_action_types/servicenow/schema.ts | 1 + .../saved_objects/actions_migrations.ts | 27 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 6fec30803d6d7..90279c8e1ea9e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), + isLegacy: schema.boolean({ defaultValue: false }), }; export const ExternalIncidentServiceConfigurationSchema = schema.object( diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index e75f3eb41f2df..a79d4be9df5c1 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -59,7 +59,14 @@ export function getActionsMigrations( const migrationActionsFourteen = createEsoMigration( encryptedSavedObjects, (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addisMissingSecretsField) + pipeMigrations(addIsMissingSecretsField) + ); + + const migrationActionsFifteen = createEsoMigration( + encryptedSavedObjects, + (doc): doc is SavedObjectUnsanitizedDoc => + doc.attributes.actionTypeId === '.servicenow', + pipeMigrations(markOldServiceNowITSMConnectorAsLegacy) ); const migrationEmailActionsSixteen = createEsoMigration( @@ -79,6 +86,7 @@ export function getActionsMigrations( '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), + '7.15.0': executeMigrationWithErrorHandling(migrationActionsFifteen, '7.15.0'), '7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), }; @@ -182,7 +190,7 @@ const setServiceConfigIfNotSet = ( }; }; -const addisMissingSecretsField = ( +const addIsMissingSecretsField = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { return { @@ -194,6 +202,21 @@ const addisMissingSecretsField = ( }; }; +const markOldServiceNowITSMConnectorAsLegacy = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + config: { + ...doc.attributes.config, + isLegacy: true, + }, + }, + }; +}; + function pipeMigrations(...migrations: ActionMigration[]): ActionMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); From e0537c6adf2be8b063162c28b37510ca61af2c20 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 13:21:07 +0300 Subject: [PATCH 05/92] Add deprecated connector --- .../servicenow/deprecated_callout.tsx | 34 +++++++++++++++ .../servicenow/servicenow_connectors.tsx | 42 +++++++++---------- .../builtin_action_types/servicenow/types.ts | 1 + 3 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx new file mode 100644 index 0000000000000..f523eb2cbd49a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const DeprecatedCalloutComponent: React.FC = () => { + return ( + <> + + + + + ); +}; + +export const DeprecatedCallout = memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 1b73ab4d91b07..bdb2fcd6c868c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -25,12 +25,13 @@ import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; import { useKibana } from '../../../../common/lib/kibana'; import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; +import { DeprecatedCallout } from './deprecated_callout'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps > = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, setCallbacks }) => { const { docLinks } = useKibana().services; - const { apiUrl } = action.config; + const { apiUrl, isLegacy } = action.config; const isApiUrlInvalid: boolean = errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; @@ -170,28 +171,23 @@ const ServiceNowConnectorFields: React.FC< fullWidth error={errors.password} isInvalid={isPasswordInvalid} - label={i18n.PASSWORD_LABEL} - > - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - /> - - - - - ); - }; + name="connector-servicenow-password" + value={password || ''} // Needed to prevent uncontrolled input error when value is undefined + data-test-subj="connector-servicenow-password-form-input" + onChange={(evt) => handleOnChangeSecretConfig('password', evt.target.value)} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + {isLegacy && } + + ); +}; // eslint-disable-next-line import/no-default-export export { ServiceNowConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index f252f4648e670..caa335eef2c63 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -29,6 +29,7 @@ export interface ServiceNowSIRActionParams { export interface ServiceNowConfig { apiUrl: string; + isLegacy: boolean; } export interface ServiceNowSecrets { From 2ea1e100289a955409fe5ebb67dada6c00d7c1ab Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 14:22:02 +0300 Subject: [PATCH 06/92] Fix callbacks types --- .../builtin_action_types/servicenow/types.ts | 6 ++++++ .../action_connector_form.tsx | 5 +++-- .../connector_add_flyout.tsx | 10 ++++------ .../connector_edit_flyout.tsx | 9 ++++----- .../plugins/triggers_actions_ui/public/types.ts | 16 ++++++++++------ 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index caa335eef2c63..c5782e2b96310 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -45,3 +45,9 @@ export interface Choice { } export type Fields = Record; +export interface AppInfo { + id: string; + name: string; + scope: string; + version: string; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index c19926b355b31..10b9eddb00a12 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -24,8 +24,7 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, ActionTypeModel, - ActionConnector, - ActionConnectorFieldsProps, + ActionConnectorFieldsSetCallbacks, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useKibana } from '../../../common/lib/kibana'; @@ -91,6 +90,7 @@ interface ActionConnectorProps< serverError?: { body: { message: string; error: string }; }; + setCallbacks: ActionConnectorFieldsSetCallbacks; setCallbacks: ActionConnectorFieldsProps['setCallbacks']; } @@ -242,6 +242,7 @@ export const ActionConnectorForm = ({ editActionSecrets={setActionSecretsProperty} consumer={consumer} setCallbacks={setCallbacks} + isEdit={isEdit} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index a18566baa3683..8cef00212f8e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useReducer, useEffect, useMemo } from 'react'; +import React, { useCallback, useState, useReducer, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -33,6 +33,7 @@ import { IErrorObject, ConnectorAddFlyoutProps, ActionTypeModel, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -121,10 +122,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ }; const [isSaving, setIsSaving] = useState(false); - const [callbacks, setCallbacks] = useState void; - afterActionConnectorSave?: () => void; - }>(null); + const [callbacks, setCallbacks] = useState(null); const closeFlyout = useCallback(() => { onClose(); @@ -207,9 +205,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ setIsSaving(true); await callbacks?.beforeActionConnectorSave?.(); const savedAction = await onActionConnectorSave(); - await callbacks?.afterActionConnectorSave?.(); setIsSaving(false); if (savedAction) { + await callbacks?.afterActionConnectorSave?.(savedAction); closeFlyout(); if (reloadConnectors) { await reloadConnectors(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 67c9b00fe1aa0..9b2e4770f8a44 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -35,6 +35,8 @@ import { IErrorObject, EditConectorTabs, UserConfiguredActionConnector, + ActionConnectorFieldsSetCallbacks, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { ConnectorReducer, createConnectorReducer } from './connector_reducer'; import { updateActionConnector, executeAction } from '../../lib/action_connector_api'; @@ -138,10 +140,7 @@ const ConnectorEditFlyout = ({ [testExecutionResult] ); - const [callbacks, setCallbacks] = useState void; - afterActionConnectorSave?: () => void; - }>(null); + const [callbacks, setCallbacks] = useState(null); const closeFlyout = useCallback(() => { setConnector(getConnectorWithoutSecrets()); @@ -256,10 +255,10 @@ const ConnectorEditFlyout = ({ setIsSaving(true); await callbacks?.beforeActionConnectorSave?.(); const savedAction = await onActionConnectorSave(); - await callbacks?.afterActionConnectorSave?.(); setIsSaving(false); if (savedAction) { setHasChanges(false); + await callbacks?.afterActionConnectorSave?.(savedAction); if (closeAfterSave) { closeFlyout(); } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 7bf5dac93065d..3ca4396ed0d99 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -73,6 +73,14 @@ export type ActionTypeRegistryContract< > = PublicMethodsOf>>; export type RuleTypeRegistryContract = PublicMethodsOf>; +export type ActionConnectorFieldsCallbacks = { + beforeActionConnectorSave?: () => Promise; + afterActionConnectorSave?: (connector: ActionConnector) => Promise; +} | null; +export type ActionConnectorFieldsSetCallbacks = React.Dispatch< + React.SetStateAction +>; + export interface ActionConnectorFieldsProps { action: TActionConnector; editActionConfig: (property: string, value: unknown) => void; @@ -80,12 +88,8 @@ export interface ActionConnectorFieldsProps { errors: IErrorObject; readOnly: boolean; consumer?: string; - setCallbacks: React.Dispatch< - React.SetStateAction<{ - beforeActionConnectorSave?: (() => void) | undefined; - afterActionConnectorSave?: ((connector: ActionConnector) => void) | undefined; - } | null> - >; + setCallbacks: ActionConnectorFieldsSetCallbacks; + isEdit: boolean; } export enum AlertFlyoutCloseReason { From c16d69d0fe43d94c2a40bc57fe69ba505b7d5980 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 14:22:54 +0300 Subject: [PATCH 07/92] Pass isEdit to connector forms --- .../sections/action_connector_form/action_connector_form.tsx | 3 ++- .../sections/action_connector_form/connector_add_flyout.tsx | 1 + .../sections/action_connector_form/connector_edit_flyout.tsx | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 10b9eddb00a12..5ee294b6dbd52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -91,7 +91,7 @@ interface ActionConnectorProps< body: { message: string; error: string }; }; setCallbacks: ActionConnectorFieldsSetCallbacks; - setCallbacks: ActionConnectorFieldsProps['setCallbacks']; + isEdit: boolean; } export const ActionConnectorForm = ({ @@ -103,6 +103,7 @@ export const ActionConnectorForm = ({ actionTypeRegistry, consumer, setCallbacks, + isEdit, }: ActionConnectorProps) => { const { docLinks, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 8cef00212f8e5..0f3d5e003abb8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -158,6 +158,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ actionTypeRegistry={actionTypeRegistry} consumer={consumer} setCallbacks={setCallbacks} + isEdit={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 9b2e4770f8a44..db05475db8723 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -320,6 +320,7 @@ const ConnectorEditFlyout = ({ actionTypeRegistry={actionTypeRegistry} consumer={consumer} setCallbacks={setCallbacks} + isEdit={true} /> {isLoading ? ( <> From 5689c65b060051a0a2ee3c5739feb67ec74dde21 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 14:23:45 +0300 Subject: [PATCH 08/92] Get application info hook --- .../builtin_action_types/servicenow/api.ts | 35 ++++++++ .../servicenow/translations.ts | 7 ++ .../servicenow/use_get_app_info.tsx | 87 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 62347580e75ca..4f11c5d7f8607 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -7,6 +7,7 @@ import { HttpSetup } from 'kibana/public'; import { BASE_ACTION_API_PATH } from '../../../constants'; +import { AppInfo } from './types'; export async function getChoices({ http, @@ -29,3 +30,37 @@ export async function getChoices({ } ); } + +// TODO: When app is certified change x_463134_elastic to the published namespace. +const getAppInfoUrl = (url: string) => `${url}/api/x_463134_elastic/elastic/health`; + +export async function getAppInfo({ + signal, + apiUrl, + username, + password, +}: { + signal: AbortSignal; + apiUrl: string; + username: string; + password: string; +}): Promise { + const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; + const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash), { + method: 'GET', + signal, + headers: { + Authorization: 'Basic ' + btoa(username + ':' + password), + }, + }); + + if (!response.ok) { + throw new Error(`Received status: ${response.status} when attempting to get app info`); + } + + const data = await response.json(); + + return { + ...data.result, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index ea646b896f5e9..a0b64a76abc18 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -196,3 +196,10 @@ export const PRIORITY_LABEL = i18n.translate( defaultMessage: 'Priority', } ); + +export const APP_INFO_API_ERROR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetAppInfoMessage', + { + defaultMessage: 'Unreachable Elastic Application in the ServiceNow instance.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx new file mode 100644 index 0000000000000..658114ddde78a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { ToastsApi } from 'kibana/public'; +import { getAppInfo } from './api'; +import { AppInfo, ServiceNowActionConnector } from './types'; +import * as i18n from './translations'; + +export interface UseGetChoicesProps { + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ServiceNowActionConnector; + onSuccess?: (appInfo: AppInfo) => void; +} + +export interface UseGetChoices { + appInfo: AppInfo | null; + isLoading: boolean; +} + +export const useGetAppInfo = ({ + connector, + toastNotifications, + onSuccess, +}: UseGetChoicesProps): UseGetChoices => { + const [isLoading, setIsLoading] = useState(false); + const [appInfo, setAppInfo] = useState(null); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + + const fetchData = useCallback(async () => { + if (!connector) { + setIsLoading(false); + return; + } + + try { + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + setIsLoading(true); + + const res = await getAppInfo({ + signal: abortCtrl.current.signal, + apiUrl: connector.config.apiUrl, + username: connector.secrets.username, + password: connector.secrets.password, + }); + + if (!didCancel.current) { + setIsLoading(false); + setAppInfo(res); + } + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.APP_INFO_API_ERROR, + text: error.message, + }); + } + } + }, [connector, toastNotifications]); + + useEffect(() => { + fetchData(); + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + setIsLoading(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + appInfo, + isLoading, + }; +}; From 748204ed2f63b384932946caf96e6c8629d723a9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 19 Jul 2021 20:31:14 +0300 Subject: [PATCH 09/92] Validate instance on save --- .../builtin_action_types/servicenow/api.ts | 4 +- .../application_required_callout.tsx | 34 +++ .../servicenow/deprecated_callout.tsx | 2 +- .../servicenow/helpers.ts | 5 +- .../servicenow/servicenow.tsx | 1 + .../servicenow/servicenow_connectors.tsx | 226 ++++++++++-------- .../servicenow/translations.ts | 2 +- .../builtin_action_types/servicenow/types.ts | 8 + .../servicenow/use_get_app_info.tsx | 68 +++--- .../connector_add_flyout.tsx | 11 +- .../connector_edit_flyout.tsx | 32 ++- 11 files changed, 240 insertions(+), 153 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 4f11c5d7f8607..e177bda821805 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -7,7 +7,7 @@ import { HttpSetup } from 'kibana/public'; import { BASE_ACTION_API_PATH } from '../../../constants'; -import { AppInfo } from './types'; +import { AppInfo, RESTApiError } from './types'; export async function getChoices({ http, @@ -44,7 +44,7 @@ export async function getAppInfo({ apiUrl: string; username: string; password: string; -}): Promise { +}): Promise { const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash), { method: 'GET', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx new file mode 100644 index 0000000000000..4110b43ca6ffd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const ApplicationRequiredCalloutComponent: React.FC = () => { + return ( + <> + + + + + ); +}; + +export const ApplicationRequiredCallout = memo(ApplicationRequiredCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx index f523eb2cbd49a..a806afee1b967 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -15,7 +15,7 @@ const DeprecatedCalloutComponent: React.FC = () => { choices.map((choice) => ({ value: choice.value, text: choice.label })); + +export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError => + (res as RESTApiError).error != null || (res as RESTApiError).status === 'failure'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 24e2a87d42357..bb4a645f10bbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -27,6 +27,7 @@ const validateConnector = async ( const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), + isLegacy: new Array(), }; const secretsErrors = { username: new Array(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index bdb2fcd6c868c..8daefbf84f176 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiFieldText, @@ -26,44 +26,76 @@ import { ServiceNowActionConnector } from './types'; import { useKibana } from '../../../../common/lib/kibana'; import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; import { DeprecatedCallout } from './deprecated_callout'; +import { useGetAppInfo } from './use_get_app_info'; +import { ApplicationRequiredCallout } from './application_required_callout'; +import { isRESTApiError } from './helpers'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, setCallbacks }) => { - const { docLinks } = useKibana().services; +> = ({ + action, + editActionSecrets, + editActionConfig, + errors, + consumer, + readOnly, + setCallbacks, + isEdit, +}) => { + const { + docLinks, + notifications: { toasts }, + } = useKibana().services; const { apiUrl, isLegacy } = action.config; - const isApiUrlInvalid: boolean = - errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; - const { username, password } = action.secrets; + const { username, password } = action.secrets; - const isUsernameInvalid: boolean = - errors.username !== undefined && errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = - errors.password !== undefined && errors.password.length > 0 && password !== undefined; + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + [editActionConfig] + ); const handleOnChangeSecretConfig = useCallback( (key: string, value: string) => editActionSecrets(key, value), [editActionSecrets] ); - const beforeActionConnectorSave = useCallback(() => { - // TODO: Validate instance - }, []); + const { fetchAppInfo, isLoading } = useGetAppInfo({ toastNotifications: toasts }); + + const [applicationRequired, setApplicationRequired] = useState(false); - const afterActionConnectorSave = useCallback(() => { + const beforeActionConnectorSave = useCallback(async () => { + if (!isLegacy) { + try { + const res = await fetchAppInfo(action); + if (isRESTApiError(res)) { + setApplicationRequired(true); + return; + } + } catch (e) { + // We need to throw here so the connector will be not be saved. + throw e; + } + } + }, [action, fetchAppInfo, isLegacy]); + + const afterActionConnectorSave = useCallback(async () => { // TODO: Implement }, []); - // Callbacks are being set only once mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setCallbacks({ beforeActionConnectorSave, afterActionConnectorSave }), []); + useEffect(() => setCallbacks({ beforeActionConnectorSave, afterActionConnectorSave }), [ + afterActionConnectorSave, + beforeActionConnectorSave, + setCallbacks, + ]); return ( <> @@ -88,86 +120,82 @@ const ServiceNowConnectorFields: React.FC< fullWidth error={errors.apiUrl} isInvalid={isApiUrlInvalid} - label={i18n.API_URL_LABEL} - helpText={ - - - - } - > - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - /> - - - - - - - -

{i18n.AUTHENTICATION_LABEL}

-
-
-
- - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + disabled={isLoading} + /> + + + + + + + +

{i18n.AUTHENTICATION_LABEL}

+
+
+
+ + + + + {getEncryptedFieldNotifyLabel( + !action.id, + 2, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + + + + + + - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - /> - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} + onBlur={() => { + if (!username) { + editActionSecrets('username', ''); + } + }} + disabled={isLoading} + /> + + + + + + + + {isLegacy && } + {applicationRequired && } ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index a0b64a76abc18..20abd5691354e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -200,6 +200,6 @@ export const PRIORITY_LABEL = i18n.translate( export const APP_INFO_API_ERROR = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetAppInfoMessage', { - defaultMessage: 'Unreachable Elastic Application in the ServiceNow instance.', + defaultMessage: 'Unreachable Elastic Application in ServiceNow instance.', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts index c5782e2b96310..b24883359dde5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/types.ts @@ -51,3 +51,11 @@ export interface AppInfo { scope: string; version: string; } + +export interface RESTApiError { + error: { + message: string; + detail: string; + }; + status: string; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx index 658114ddde78a..c3551f70a7998 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { ToastsApi } from 'kibana/public'; import { getAppInfo } from './api'; -import { AppInfo, ServiceNowActionConnector } from './types'; +import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; import * as i18n from './translations'; export interface UseGetChoicesProps { @@ -16,72 +16,62 @@ export interface UseGetChoicesProps { ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; - connector?: ServiceNowActionConnector; - onSuccess?: (appInfo: AppInfo) => void; } export interface UseGetChoices { - appInfo: AppInfo | null; + fetchAppInfo: (connector: ServiceNowActionConnector) => Promise; isLoading: boolean; } -export const useGetAppInfo = ({ - connector, - toastNotifications, - onSuccess, -}: UseGetChoicesProps): UseGetChoices => { +export const useGetAppInfo = ({ toastNotifications }: UseGetChoicesProps): UseGetChoices => { const [isLoading, setIsLoading] = useState(false); - const [appInfo, setAppInfo] = useState(null); const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); - const fetchData = useCallback(async () => { - if (!connector) { - setIsLoading(false); - return; - } + const fetchAppInfo = useCallback( + async (connector) => { + try { + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + setIsLoading(true); - try { - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - setIsLoading(true); + const res = await getAppInfo({ + signal: abortCtrl.current.signal, + apiUrl: connector.config.apiUrl, + username: connector.secrets.username, + password: connector.secrets.password, + }); - const res = await getAppInfo({ - signal: abortCtrl.current.signal, - apiUrl: connector.config.apiUrl, - username: connector.secrets.username, - password: connector.secrets.password, - }); + if (!didCancel.current) { + setIsLoading(false); + } - if (!didCancel.current) { - setIsLoading(false); - setAppInfo(res); - } - } catch (error) { - if (!didCancel.current) { - setIsLoading(false); + return res; + } catch (error) { + if (!didCancel.current) { + setIsLoading(false); + } toastNotifications.addDanger({ title: i18n.APP_INFO_API_ERROR, text: error.message, }); + throw error; } - } - }, [connector, toastNotifications]); + }, + [toastNotifications] + ); useEffect(() => { - fetchData(); - return () => { didCancel.current = true; abortCtrl.current.abort(); setIsLoading(false); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { - appInfo, + fetchAppInfo, isLoading, }; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 0f3d5e003abb8..16466fc9a210d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -203,9 +203,18 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ); return; } + setIsSaving(true); - await callbacks?.beforeActionConnectorSave?.(); + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); + setIsSaving(false); if (savedAction) { await callbacks?.afterActionConnectorSave?.(savedAction); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index db05475db8723..206ae0bf5018b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -35,7 +35,6 @@ import { IErrorObject, EditConectorTabs, UserConfiguredActionConnector, - ActionConnectorFieldsSetCallbacks, ActionConnectorFieldsCallbacks, } from '../../../types'; import { ConnectorReducer, createConnectorReducer } from './connector_reducer'; @@ -240,22 +239,35 @@ const ConnectorEditFlyout = ({ }); }; + const setConnectorWithErrors = () => + setConnector( + getConnectorWithInvalidatedFields( + connector, + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors + ) + ); + const onSaveClicked = async (closeAfterSave: boolean = true) => { if (hasErrors) { - setConnector( - getConnectorWithInvalidatedFields( - connector, - errors.configErrors, - errors.secretsErrors, - errors.connectorBaseErrors - ) - ); + setConnectorWithErrors(); return; } + setIsSaving(true); - await callbacks?.beforeActionConnectorSave?.(); + + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); setIsSaving(false); + if (savedAction) { setHasChanges(false); await callbacks?.afterActionConnectorSave?.(savedAction); From 09be958e125a5decb7199cf4cd3d189c144c1ff3 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 20 Jul 2021 13:54:18 +0300 Subject: [PATCH 10/92] Support both legacy and new app --- .../builtin_action_types/servicenow/api.ts | 7 +- .../servicenow/service.ts | 82 ++++++++++++------- .../builtin_action_types/servicenow/types.ts | 14 ++-- 3 files changed, 68 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 3254ed09251d1..958c1375c00b3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -19,7 +19,11 @@ import { } from './types'; const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; -const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; +const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => { + const { externalId: id } = params; + const res = await externalService.getIncident(id); + return res; +}; const pushToServiceHandler = async ({ externalService, @@ -41,6 +45,7 @@ const pushToServiceHandler = async ({ res = await externalService.createIncident({ incident: { ...incident, + caller_id: secrets.username, opened_by: secrets.username, }, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 29ec64e8d8f88..7c836a8f1ab59 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -10,10 +10,12 @@ import axios, { AxiosResponse } from 'axios'; import { ExternalServiceCredentials, ExternalService, - ExternalServiceParams, + ExternalServiceParamsCreate, + ExternalServiceParamsUpdate, ImportSetApiResponse, ImportSetApiResponseError, ServiceNowIncident, + Incident, } from './types'; import * as i18n from './translations'; @@ -23,19 +25,21 @@ import { ServiceNowSecretConfigurationType, ResponseError, } from './types'; -import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils'; +import { request, getErrorMessage, addTimeZoneToDate } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; -const API_VERSION = 'v2'; -const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`; +const SYS_DICTIONARY = `api/now/table/sys_dictionary`; +// TODO: Change it to production when the app is ready const IMPORTATION_SET_TABLE = 'x_463134_elastic_import_set_web_service'; const FIELD_PREFIX = 'u_'; -const prefixFields = (incident: ExternalServiceParams['incident']) => - Object.entries(incident).reduce( - (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), - {} - ); +const prepareIncident = (isLegacy: boolean, incident: Incident): Incident => + isLegacy + ? incident + : Object.entries(incident).reduce( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} as Incident + ); export const createExternalService = ( table: string, @@ -43,7 +47,7 @@ export const createExternalService = ( logger: Logger, configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { - const { apiUrl: url } = config as ServiceNowPublicConfigurationType; + const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; if (!url || !username || !password) { @@ -52,13 +56,18 @@ export const createExternalService = ( const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${IMPORTATION_SET_TABLE}`; - const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`; + const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/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 choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`; + const axiosInstance = axios.create({ auth: { username, password }, }); + const getCreateIncidentUrl = () => (isLegacy ? tableApiIncidentUrl : importSetTableUrl); + const getUpdateIncidentUrl = (incidentId: string) => + isLegacy ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; + const getIncidentViewURL = (id: string) => { // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`; @@ -112,7 +121,7 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${id}`, + url: `${tableApiIncidentUrl}/${id}`, logger, configurationUtilities, }); @@ -136,7 +145,7 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: incidentUrl, + url: tableApiIncidentUrl, logger, params, configurationUtilities, @@ -155,20 +164,25 @@ export const createExternalService = ( } }; - const createIncident = async ({ incident }: ExternalServiceParams) => { + const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { const res = await request({ axios: axiosInstance, - url: importSetTableUrl, + url: getCreateIncidentUrl(), logger, method: 'post', - data: prefixFields(incident), + data: prepareIncident(isLegacy, incident), configurationUtilities, }); checkInstance(res); - throwIfImportSetApiResponseIsAnError(res.data); - const insertedIncident = await getIncident(res.data.result[0].sys_id); + + if (!isLegacy) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const incidentId = isLegacy ? res.data.result.sys_id : res.data.result[0].sys_id; + const insertedIncident = await getIncident(incidentId); return { title: insertedIncident.number, @@ -188,26 +202,36 @@ export const createExternalService = ( } }; - const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { const res = await request({ axios: axiosInstance, - url: importSetTableUrl, - method: 'post', + url: getUpdateIncidentUrl(incidentId), + // Import Set API supports only POST. + method: isLegacy ? 'patch' : 'post', logger, - data: { ...prefixFields(incident), u_incident_id: incidentId }, + data: { + ...prepareIncident(isLegacy, incident), + // u_incident_id is used to update the incident when using the Import Set API. + ...(isLegacy ? {} : { u_incident_id: incidentId }), + }, configurationUtilities, }); checkInstance(res); - throwIfImportSetApiResponseIsAnError(res.data); - const insertedIncident = await getIncident(res.data.result[0].sys_id); + + if (!isLegacy) { + throwIfImportSetApiResponseIsAnError(res.data); + } + + const id = isLegacy ? res.data.result.sys_id : res.data.result[0].sys_id; + const updatedIncident = await getIncident(id); return { - title: insertedIncident.number, - id: insertedIncident.sys_id, - pushedDate: new Date(addTimeZoneToDate(insertedIncident.sys_updated_on)).toISOString(), - url: getIncidentViewURL(insertedIncident.sys_id), + title: updatedIncident.number, + id: updatedIncident.sys_id, + pushedDate: new Date(addTimeZoneToDate(updatedIncident.sys_updated_on)).toISOString(), + url: getIncidentViewURL(updatedIncident.sys_id), }; } catch (error) { throw new Error( diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 0aad77f7e87b5..9871d1b920709 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -78,15 +78,19 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { comments?: ExternalServiceCommentResponse[]; } -export type ExternalServiceParams = Record; +export interface ExternalServiceParamsCreate { + incident: Incident & Record; +} + +export type ExternalServiceParamsUpdate = ExternalServiceParamsCreate & { incidentId: string }; export interface ExternalService { getChoices: (fields: string[]) => Promise; - getIncident: (id: string) => Promise; + getIncident: (id: string) => Promise; getFields: () => Promise; - createIncident: (params: ExternalServiceParams) => Promise; - updateIncident: (params: ExternalServiceParams) => Promise; - findIncidents: (params?: Record) => Promise; + createIncident: (params: ExternalServiceParamsCreate) => Promise; + updateIncident: (params: ExternalServiceParamsUpdate) => Promise; + findIncidents: (params?: Record) => Promise; } export type PushToServiceApiParams = ExecutorSubActionPushParams; From 518011fd6facb347aa775ff8720a1e860c409c16 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 20 Jul 2021 14:11:13 +0300 Subject: [PATCH 11/92] Seperate SIR --- .../builtin_action_types/servicenow/config.ts | 12 ++ .../builtin_action_types/servicenow/index.ts | 26 ++- .../servicenow/service.ts | 31 ++-- .../servicenow/servicenow.tsx | 2 +- .../servicenow/servicenow_connectors_sir.tsx | 172 ++++++++++++++++++ 5 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..c4d26ebed58c4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const serviceNowITSMTable = 'incident'; +export const serviceNowSIRTable = 'sn_si_incident'; + +export const ServiceNowITSMActionTypeId = '.servicenow'; +export const ServiceNowSIRActionTypeId = '.servicenow-sir'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index f2b500df6ccb3..c02d2477927f0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -31,6 +31,19 @@ import { ServiceNowExecutorResultData, ExecutorSubActionGetChoicesParams, } from './types'; +import { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, +} from './config'; + +export { + ServiceNowITSMActionTypeId, + serviceNowITSMTable, + ServiceNowSIRActionTypeId, + serviceNowSIRTable, +}; export type ActionParamsType = | TypeOf @@ -41,12 +54,6 @@ interface GetActionTypeParams { configurationUtilities: ActionsConfigurationUtilities; } -const serviceNowITSMTable = 'incident'; -const serviceNowSIRTable = 'sn_si_incident'; - -export const ServiceNowITSMActionTypeId = '.servicenow'; -export const ServiceNowSIRActionTypeId = '.servicenow-sir'; - export type ServiceNowActionType = ActionType< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -81,6 +88,7 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic configurationUtilities, table: serviceNowITSMTable, commentFieldKey: 'work_notes', + actionTypeId: ServiceNowITSMActionTypeId, }), }; } @@ -105,6 +113,7 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service configurationUtilities, table: serviceNowSIRTable, commentFieldKey: 'work_notes', + actionTypeId: ServiceNowSIRActionTypeId, }), }; } @@ -116,11 +125,13 @@ async function executor( logger, configurationUtilities, table, + actionTypeId, commentFieldKey = 'comments', }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; table: string; + actionTypeId: string; commentFieldKey?: string; }, execOptions: ServiceNowActionTypeExecutorOptions @@ -136,7 +147,8 @@ async function executor( secrets, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 7c836a8f1ab59..0df8d32e6d429 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -27,14 +27,15 @@ import { } from './types'; import { request, getErrorMessage, addTimeZoneToDate } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ServiceNowSIRActionTypeId } from './config'; const SYS_DICTIONARY = `api/now/table/sys_dictionary`; // TODO: Change it to production when the app is ready const IMPORTATION_SET_TABLE = 'x_463134_elastic_import_set_web_service'; const FIELD_PREFIX = 'u_'; -const prepareIncident = (isLegacy: boolean, incident: Incident): Incident => - isLegacy +const prepareIncident = (useOldApi: boolean, incident: Incident): Incident => + useOldApi ? incident : Object.entries(incident).reduce( (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), @@ -45,7 +46,8 @@ export const createExternalService = ( table: string, { config, secrets }: ExternalServiceCredentials, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + actionTypeId: string ): ExternalService => { const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; @@ -64,9 +66,12 @@ export const createExternalService = ( auth: { username, password }, }); - const getCreateIncidentUrl = () => (isLegacy ? tableApiIncidentUrl : importSetTableUrl); + // TODO: Remove ServiceNow SIR check when there is a SN Store app for SIR. + const useOldApi = isLegacy || actionTypeId === ServiceNowSIRActionTypeId; + + const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); const getUpdateIncidentUrl = (incidentId: string) => - isLegacy ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; + useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; const getIncidentViewURL = (id: string) => { // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html @@ -171,17 +176,17 @@ export const createExternalService = ( url: getCreateIncidentUrl(), logger, method: 'post', - data: prepareIncident(isLegacy, incident), + data: prepareIncident(useOldApi, incident), configurationUtilities, }); checkInstance(res); - if (!isLegacy) { + if (!useOldApi) { throwIfImportSetApiResponseIsAnError(res.data); } - const incidentId = isLegacy ? res.data.result.sys_id : res.data.result[0].sys_id; + const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; const insertedIncident = await getIncident(incidentId); return { @@ -208,23 +213,23 @@ export const createExternalService = ( axios: axiosInstance, url: getUpdateIncidentUrl(incidentId), // Import Set API supports only POST. - method: isLegacy ? 'patch' : 'post', + method: useOldApi ? 'patch' : 'post', logger, data: { - ...prepareIncident(isLegacy, incident), + ...prepareIncident(useOldApi, incident), // u_incident_id is used to update the incident when using the Import Set API. - ...(isLegacy ? {} : { u_incident_id: incidentId }), + ...(useOldApi ? {} : { u_incident_id: incidentId }), }, configurationUtilities, }); checkInstance(res); - if (!isLegacy) { + if (!useOldApi) { throwIfImportSetApiResponseIsAnError(res.data); } - const id = isLegacy ? res.data.result.sys_id : res.data.result[0].sys_id; + const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; const updatedIncident = await getIncident(id); return { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index bb4a645f10bbc..d9e25916a76b3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -137,7 +137,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel< selectMessage: SERVICENOW_SIR_DESC, actionTypeTitle: SERVICENOW_SIR_TITLE, validateConnector, - actionConnectorFields: lazy(() => import('./servicenow_connectors')), + actionConnectorFields: lazy(() => import('./servicenow_connectors_sir')), validateParams: async ( actionParams: ServiceNowSIRActionParams ): Promise> => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx new file mode 100644 index 0000000000000..ef354f63f8ca7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; + +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiSpacer, + EuiLink, + EuiTitle, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../types'; + +import * as i18n from './translations'; +import { ServiceNowActionConnector } from './types'; +import { useKibana } from '../../../../common/lib/kibana'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; + +// TODO: Remove when SN SIR has its own SN Store application. + +const ServiceNowConnectorSIRFields: React.FC< + ActionConnectorFieldsProps +> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; + + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; + + const { username, password } = action.secrets; + + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; + + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + [editActionConfig] + ); + + const handleOnChangeSecretConfig = useCallback( + (key: string, value: string) => editActionSecrets(key, value), + [editActionSecrets] + ); + return ( + <> + + + + + + } + > + handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + + + +

{i18n.AUTHENTICATION_LABEL}

+
+
+
+ + + + + {getEncryptedFieldNotifyLabel( + !action.id, + 2, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + + + + + + handleOnChangeSecretConfig('username', evt.target.value)} + onBlur={() => { + if (!username) { + editActionSecrets('username', ''); + } + }} + /> + + + + + + + + handleOnChangeSecretConfig('password', evt.target.value)} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowConnectorSIRFields as default }; From 503755b06d52ef1dfe7cf9ddab4482d0c4f117b8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 20 Jul 2021 14:30:42 +0300 Subject: [PATCH 12/92] Log application version & and throw otherwise --- .../servicenow/service.ts | 45 ++++++++++++++++++- .../builtin_action_types/servicenow/types.ts | 6 +++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 0df8d32e6d429..741fbab51585b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -16,6 +16,7 @@ import { ImportSetApiResponseError, ServiceNowIncident, Incident, + GetApplicationInfoResponse, } from './types'; import * as i18n from './translations'; @@ -61,6 +62,8 @@ export const createExternalService = ( const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/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/table/sys_choice`; + // TODO: Change it to production when the app is ready + const getVersionUrl = `${urlWithoutTrailingSlash}/api/x_463134_elastic/elastic/health`; const axiosInstance = axios.create({ auth: { username, password }, @@ -98,11 +101,11 @@ export const createExternalService = ( const createErrorMessage = (errorResponse: ResponseError): string => { if (errorResponse == null) { - return ''; + return 'unknown'; } const { error } = errorResponse; - return error != null ? `${error?.message}: ${error?.detail}` : ''; + return error != null ? `${error?.message}: ${error?.detail}` : 'unknown'; }; const isImportSetApiResponseAnError = ( @@ -122,6 +125,34 @@ export const createExternalService = ( } }; + /** + * Gets the Elastic SN Application information including the current version. + * It should not be used on legacy connectors. + */ + const getApplicationInformation = async (): Promise => { + try { + const res = await request({ + axios: axiosInstance, + url: getVersionUrl, + logger, + configurationUtilities, + }); + + checkInstance(res); + + return { ...res.data.result }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.SERVICENOW, + `Unable to get application version. Error: ${error.message} Reason: ${createErrorMessage( + error.response?.data + )}` + ) + ); + } + }; + const getIncident = async (id: string): Promise => { try { const res = await request({ @@ -171,6 +202,11 @@ export const createExternalService = ( const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { + if (!useOldApi) { + const { version } = await getApplicationInformation(); + logger.debug(`Create incident: Current elastic application version: ${version}`); + } + const res = await request({ axios: axiosInstance, url: getCreateIncidentUrl(), @@ -209,6 +245,11 @@ export const createExternalService = ( const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { + if (!useOldApi) { + const { version } = await getApplicationInformation(); + logger.debug(`Create incident: Current elastic application version: ${version}`); + } + const res = await request({ axios: axiosInstance, url: getUpdateIncidentUrl(incidentId), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 9871d1b920709..0d8fee8b1fd5b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -219,3 +219,9 @@ export interface ImportSetApiResponseError { } export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; +export interface GetApplicationInfoResponse { + id: string; + name: string; + scope: string; + version: string; +} From 8962a4978a6f7d8f7b9fb29d8b78548d6010440e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 20 Jul 2021 14:51:56 +0300 Subject: [PATCH 13/92] Deprecated tooltip cases --- .../configure_cases/connectors_dropdown.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 3cab2afd41f41..81f3f540c170a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -6,7 +6,7 @@ */ import React, { useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiIconTip, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; @@ -79,16 +79,27 @@ const ConnectorsDropdownComponent: React.FC = ({ { value: connector.id, inputDisplay: ( - + - + {connector.name} + {connector.config.isLegacy && ( + + + + )} ), 'data-test-subj': `dropdown-connector-${connector.id}`, From 3685dd1e4c5c0abb4fa8e53936d2de353ada59ce Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 20 Jul 2021 15:01:07 +0300 Subject: [PATCH 14/92] Deprecated tooltip alerts --- .../components/actions_connectors_list.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index c237bbda48658..2e1a40c00ccbf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -38,6 +38,7 @@ import { ActionConnectorTableItem, ActionTypeIndex, EditConectorTabs, + UserConfiguredActionConnector, } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; import { useKibana } from '../../../../common/lib/kibana'; @@ -190,6 +191,18 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} + {(item as UserConfiguredActionConnector< + Record, + Record + >).config.isLegacy && ( + + )} ); From 0fb18072dc122af681f9002e6e5dff3a1c8dba71 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 21 Jul 2021 13:39:29 +0300 Subject: [PATCH 15/92] Improve message --- .../servicenow/application_required_callout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 4110b43ca6ffd..ae5f9a97f91d6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -22,7 +22,7 @@ const ApplicationRequiredCalloutComponent: React.FC = () => { 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout', { defaultMessage: - 'The Elastic App is not installed. Please go to the ServiceNow Store and install the application.', + 'The Elastic App is not installed. Please install the application from the ServiceNow Store.', } )} /> From 6e677e1fc9b1901b476cb2c513902f4e3fa29d7d Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sun, 25 Jul 2021 14:13:04 +0300 Subject: [PATCH 16/92] Improve translation --- .../servicenow/application_required_callout.tsx | 2 +- .../servicenow/servicenow_connectors.tsx | 13 +++++++++++++ .../builtin_action_types/servicenow/translations.ts | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index ae5f9a97f91d6..1323b92d8558d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -22,7 +22,7 @@ const ApplicationRequiredCalloutComponent: React.FC = () => { 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout', { defaultMessage: - 'The Elastic App is not installed. Please install the application from the ServiceNow Store.', + 'In order to create this connector, please go to the ServiceNow Store and install the Elastic application.', } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 8daefbf84f176..e1f55c3c01795 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -213,6 +213,19 @@ const ServiceNowConnectorFields: React.FC< + + + {i18n.INSTALL} + + ), + }} + /> + {isLegacy && } {applicationRequired && } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 20abd5691354e..85400d1a8e71f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -203,3 +203,10 @@ export const APP_INFO_API_ERROR = i18n.translate( defaultMessage: 'Unreachable Elastic Application in ServiceNow instance.', } ); + +export const INSTALL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.install', + { + defaultMessage: 'install', + } +); From fdb8fc89a5f691823ca755fc5d2c20a42d8a4ff8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 5 Aug 2021 12:12:53 +0300 Subject: [PATCH 17/92] Change to elastic table & fix types --- .../servicenow/service.test.ts | 48 +++++++++++-------- .../servicenow/service.ts | 6 +-- .../builtin_action_types/servicenow/types.ts | 10 ++-- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 37bfb662508a2..4ae481f3badaa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -9,7 +9,7 @@ import axios from 'axios'; import { createExternalService } from './service'; import * as utils from '../lib/axios_utils'; -import { ExternalService } from './types'; +import { ExternalService, ServiceNowITSMIncident } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; @@ -31,6 +31,7 @@ const requestMock = utils.request as jest.Mock; const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); const table = 'incident'; +const actionTypeId = '.servicenow'; describe('ServiceNow service', () => { let service: ExternalService; @@ -45,7 +46,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); }); @@ -63,7 +65,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ) ).toThrow(); }); @@ -77,7 +80,8 @@ describe('ServiceNow service', () => { secrets: { username: '', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ) ).toThrow(); }); @@ -91,7 +95,8 @@ describe('ServiceNow service', () => { secrets: { username: '', password: undefined }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ) ).toThrow(); }); @@ -128,7 +133,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); requestMock.mockImplementation(() => ({ @@ -172,7 +178,7 @@ describe('ServiceNow service', () => { })); const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }); expect(res).toEqual({ @@ -189,7 +195,7 @@ describe('ServiceNow service', () => { })); await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }); expect(requestMock).toHaveBeenCalledWith({ @@ -210,7 +216,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); requestMock.mockImplementation(() => ({ @@ -218,7 +225,7 @@ describe('ServiceNow service', () => { })); const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }); expect(requestMock).toHaveBeenCalledWith({ @@ -242,7 +249,7 @@ describe('ServiceNow service', () => { await expect( service.createIncident({ - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }) ).rejects.toThrow( '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' @@ -269,7 +276,7 @@ describe('ServiceNow service', () => { const res = await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }); expect(res).toEqual({ @@ -287,7 +294,7 @@ describe('ServiceNow service', () => { await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }); expect(patchMock).toHaveBeenCalledWith({ @@ -307,7 +314,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); patchMock.mockImplementation(() => ({ @@ -316,7 +324,7 @@ describe('ServiceNow service', () => { const res = await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }); expect(patchMock).toHaveBeenCalledWith({ @@ -340,7 +348,7 @@ describe('ServiceNow service', () => { await expect( service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' }, + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }) ).rejects.toThrow( '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' @@ -354,7 +362,7 @@ describe('ServiceNow service', () => { const res = await service.updateIncident({ incidentId: '1', - comment: 'comment-1', + incident: { comment: 'comment-1' }, }); expect(res).toEqual({ @@ -408,7 +416,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); requestMock.mockImplementation(() => ({ @@ -476,7 +485,8 @@ describe('ServiceNow service', () => { secrets: { username: 'admin', password: 'admin' }, }, logger, - configurationUtilities + configurationUtilities, + actionTypeId ); requestMock.mockImplementation(() => ({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 741fbab51585b..8bea98c5b886c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -17,6 +17,7 @@ import { ServiceNowIncident, Incident, GetApplicationInfoResponse, + PartialIncident, } from './types'; import * as i18n from './translations'; @@ -31,11 +32,10 @@ import { ActionsConfigurationUtilities } from '../../actions_config'; import { ServiceNowSIRActionTypeId } from './config'; const SYS_DICTIONARY = `api/now/table/sys_dictionary`; -// TODO: Change it to production when the app is ready -const IMPORTATION_SET_TABLE = 'x_463134_elastic_import_set_web_service'; +const IMPORTATION_SET_TABLE = 'x_elas2_inc_int_elastic_incident'; const FIELD_PREFIX = 'u_'; -const prepareIncident = (useOldApi: boolean, incident: Incident): Incident => +const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => useOldApi ? incident : Object.entries(incident).reduce( diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 0d8fee8b1fd5b..b14728b753853 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -78,11 +78,17 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse { comments?: ExternalServiceCommentResponse[]; } +export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; +export type PartialIncident = Partial; + export interface ExternalServiceParamsCreate { incident: Incident & Record; } -export type ExternalServiceParamsUpdate = ExternalServiceParamsCreate & { incidentId: string }; +export interface ExternalServiceParamsUpdate { + incidentId: string; + incident: PartialIncident & Record; +} export interface ExternalService { getChoices: (fields: string[]) => Promise; @@ -119,8 +125,6 @@ export type ServiceNowSIRIncident = Omit< 'externalId' >; -export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident; - export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; secrets: Record; From 688f83d69fadf141812a4157e91d1303da4fd80c Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 5 Aug 2021 13:03:42 +0300 Subject: [PATCH 18/92] Add callbacks to add modal --- .../action_connector_form/connector_add_modal.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 1e9669d1995dd..7fd6931c936f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -33,6 +33,7 @@ import { ActionTypeRegistryContract, UserConfiguredActionConnector, IErrorObject, + ActionConnectorFieldsCallbacks, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; @@ -97,6 +98,7 @@ const ConnectorAddModal = ({ secretsErrors: {}, }); + const [callbacks, setCallbacks] = useState(null); const actionTypeModel = actionTypeRegistry.get(actionType.id); useEffect(() => { @@ -189,6 +191,8 @@ const ConnectorAddModal = ({ errors={errors.connectorErrors} actionTypeRegistry={actionTypeRegistry} consumer={consumer} + setCallbacks={setCallbacks} + isEdit={false} /> {isLoading ? ( <> @@ -230,9 +234,19 @@ const ConnectorAddModal = ({ return; } setIsSaving(true); + // Do not allow to save the connector if there is an error + try { + await callbacks?.beforeActionConnectorSave?.(); + } catch (e) { + setIsSaving(false); + return; + } + const savedAction = await onActionConnectorSave(); + setIsSaving(false); if (savedAction) { + await callbacks?.afterActionConnectorSave?.(savedAction); if (postSaveEventHandler) { postSaveEventHandler(savedAction); } From 44f0e23cdc62f08af780c2c9e2df7a3b03cb9589 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 5 Aug 2021 13:14:01 +0300 Subject: [PATCH 19/92] Pass new props to tests --- .../email/email_connector.test.tsx | 10 ++++++++++ .../es_index/es_index_connector.test.tsx | 2 ++ .../jira/jira_connectors.test.tsx | 10 ++++++++++ .../pagerduty/pagerduty_connectors.test.tsx | 8 ++++++++ .../resilient/resilient_connectors.test.tsx | 10 ++++++++++ .../servicenow/servicenow_connectors.test.tsx | 13 +++++++++++-- .../slack/slack_connectors.test.tsx | 8 ++++++++ .../swimlane/swimlane_connectors.test.tsx | 14 ++++++++++++++ .../teams/teams_connectors.test.tsx | 8 ++++++++ .../webhook/webhook_connectors.test.tsx | 8 ++++++++ .../action_connector_form.test.tsx | 2 ++ 11 files changed, 91 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index 0b446b99c93dc..f1c740b9a3acd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -35,6 +35,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -66,6 +68,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -223,6 +227,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -245,6 +251,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -268,6 +276,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index e804ce2a9f54d..9ef498334ad3d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -71,6 +71,8 @@ describe('IndexActionConnectorFields renders', () => { editActionSecrets: () => {}, errors: { index: [] }, readOnly: false, + setCallbacks: () => {}, + isEdit: false, }; const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index be5250ccf8b29..4859c25adcc06 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -34,6 +34,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -74,6 +76,8 @@ describe('JiraActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); @@ -104,6 +108,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -125,6 +131,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -152,6 +160,8 @@ describe('JiraActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index 86347de528a01..8be15ddaa6bca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -33,6 +33,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -61,6 +63,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -86,6 +90,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -112,6 +118,8 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index bbd237a7cec89..35891f513be6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -34,6 +34,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -74,6 +76,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -105,6 +109,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -126,6 +132,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -153,6 +161,8 @@ describe('ResilientActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 4993c51f350ad..02f3ae47728ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -33,6 +33,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect( @@ -57,8 +59,7 @@ describe('ServiceNowActionConnectorFields renders', () => { name: 'servicenow', config: { apiUrl: 'https://test/', - incidentConfiguration: { mapping: [] }, - isCaseOwned: true, + isLegacy: false, }, } as ServiceNowActionConnector; const wrapper = mountWithIntl( @@ -69,6 +70,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionSecrets={() => {}} readOnly={false} consumer={'case'} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); @@ -91,6 +94,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -112,6 +117,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -138,6 +145,8 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx index 547346054011b..0a37165bd7f5f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -30,6 +30,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -56,6 +58,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -76,6 +80,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); @@ -98,6 +104,8 @@ describe('SlackActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx index 6740179d786f2..4829156380e94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane_connectors.test.tsx @@ -50,6 +50,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -77,6 +79,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -106,6 +110,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -139,6 +145,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -184,6 +192,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -229,6 +239,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -285,6 +297,8 @@ describe('SwimlaneActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx index 11c747125595d..5031b32281258 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx @@ -30,6 +30,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); @@ -56,6 +58,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -79,6 +83,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -103,6 +109,8 @@ describe('TeamsActionFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index c041b4e3e1e42..ea40c1ddfb139 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -35,6 +35,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); @@ -62,6 +64,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); @@ -92,6 +96,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); @@ -123,6 +129,8 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 091ea1e305e35..5a4d682ff573b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -49,6 +49,8 @@ describe('action_connector_form', () => { dispatch={() => {}} errors={{ name: [] }} actionTypeRegistry={actionTypeRegistry} + setCallbacks={() => {}} + isEdit={false} /> ); const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]'); From 4775d62d538c2a2137d4bfced42f4706f0500d62 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 5 Aug 2021 14:39:18 +0300 Subject: [PATCH 20/92] Change health api url to production --- .../server/builtin_action_types/servicenow/service.ts | 7 +++++-- .../components/builtin_action_types/servicenow/api.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 8bea98c5b886c..5ddf592bdaa72 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -62,8 +62,11 @@ export const createExternalService = ( const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/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/table/sys_choice`; - // TODO: Change it to production when the app is ready - const getVersionUrl = `${urlWithoutTrailingSlash}/api/x_463134_elastic/elastic/health`; + /** + * Need to be set the same at: + * x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts + */ + const getVersionUrl = `${urlWithoutTrailingSlash}/api/x_elas2_inc_int/elastic_api/health`; const axiosInstance = axios.create({ auth: { username, password }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index e177bda821805..86b930be6f193 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -31,8 +31,11 @@ export async function getChoices({ ); } -// TODO: When app is certified change x_463134_elastic to the published namespace. -const getAppInfoUrl = (url: string) => `${url}/api/x_463134_elastic/elastic/health`; +/** + * The app info url should be the same as at: + * x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts + */ +const getAppInfoUrl = (url: string) => `${url}/api/x_elas2_inc_int/elastic_api/health`; export async function getAppInfo({ signal, From 9e5680322ed0e0ab7e94a29d5ced06c1a2ec8794 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 5 Aug 2021 15:07:36 +0300 Subject: [PATCH 21/92] Better installation message --- .../servicenow/installation_callout.tsx | 41 +++++++++++++++++++ .../servicenow/servicenow_connectors.tsx | 15 +------ .../servicenow/translations.ts | 7 ++++ 3 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx new file mode 100644 index 0000000000000..bd824a2fb4158 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import * as i18n from './translations'; + +const InstallationCalloutComponent: React.FC = () => { + return ( + <> + + + + {i18n.INSTALL} + + ), + }} + /> + + + + ); +}; + +export const InstallationCallout = memo(InstallationCalloutComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index e1f55c3c01795..0990256a9c774 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -29,6 +29,7 @@ import { DeprecatedCallout } from './deprecated_callout'; import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; import { isRESTApiError } from './helpers'; +import { InstallationCallout } from './installation_callout'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps @@ -99,6 +100,7 @@ const ServiceNowConnectorFields: React.FC< return ( <> + {!isLegacy && } - - - {i18n.INSTALL} - - ), - }} - /> - {isLegacy && } {applicationRequired && } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 85400d1a8e71f..799d7e25e657e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -210,3 +210,10 @@ export const INSTALL = i18n.translate( defaultMessage: 'install', } ); + +export const INSTALLATION_CALLOUT_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutInfo', + { + defaultMessage: 'Elastic ServiceNow Application', + } +); From b5eef2fbfca4bb01527970e7b187149530969d96 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 5 Aug 2021 18:24:20 +0300 Subject: [PATCH 22/92] Migrate connectors functionality --- .../servicenow/deprecated_callout.tsx | 35 +++++-- .../migration_confirmation_modal.tsx | 76 ++++++++++++++++ .../servicenow/servicenow_connectors.tsx | 91 ++++++++++++++++--- .../servicenow/translations.ts | 18 ++++ 4 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx index a806afee1b967..247b23758cdc6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -6,26 +6,47 @@ */ import React, { memo } from 'react'; -import { EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; -const DeprecatedCalloutComponent: React.FC = () => { +interface Props { + onMigrate: () => void; +} + +const DeprecatedCalloutComponent: React.FC = ({ onMigrate }) => { return ( <> + > + + {i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate', + { + defaultMessage: 'migrate', + } + )} + + ), + }} + /> + ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx new file mode 100644 index 0000000000000..ca7b37fd15e79 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiConfirmModal, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const title = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', + { + defaultMessage: 'ServiceNow connector migration', + } +); + +const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', + { + defaultMessage: 'Do not migrate', + } +); + +const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', + { + defaultMessage: 'Migrate', + } +); + +const modalText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalText', + { + defaultMessage: + 'You are about to migrate to the new connector. This action cannot be reversed.', + } +); + +const modalErrorMessage = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalErrorMessage', + { + defaultMessage: 'Invalid configuration or secrets', + } +); + +interface Props { + hasErrors?: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +const MigrationConfirmationModalComponent: React.FC = ({ + onCancel, + onConfirm, + hasErrors = true, +}) => { + return ( + +

{modalText}

+

{hasErrors && {modalErrorMessage}}

+
+ ); +}; + +export const MigrationConfirmationModal = memo(MigrationConfirmationModalComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 0990256a9c774..7a1347c758c68 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -30,6 +30,8 @@ import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; import { isRESTApiError } from './helpers'; import { InstallationCallout } from './installation_callout'; +import { MigrationConfirmationModal } from './migration_confirmation_modal'; +import { updateActionConnector } from '../../../lib/action_connector_api'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps @@ -44,6 +46,7 @@ const ServiceNowConnectorFields: React.FC< isEdit, }) => { const { + http, docLinks, notifications: { toasts }, } = useKibana().services; @@ -51,14 +54,22 @@ const ServiceNowConnectorFields: React.FC< const isApiUrlInvalid: boolean = errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; - const { username, password } = action.secrets; - const isUsernameInvalid: boolean = errors.username !== undefined && errors.username.length > 0 && username !== undefined; const isPasswordInvalid: boolean = errors.password !== undefined && errors.password.length > 0 && password !== undefined; + const hasErrorsOrEmptyFields = + apiUrl === undefined || + username === undefined || + password === undefined || + isApiUrlInvalid || + isUsernameInvalid || + isPasswordInvalid; + + const [showModal, setShowModal] = useState(false); + const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), [editActionConfig] @@ -73,20 +84,26 @@ const ServiceNowConnectorFields: React.FC< const [applicationRequired, setApplicationRequired] = useState(false); + const getApplicationInfo = useCallback(async () => { + try { + const res = await fetchAppInfo(action); + if (isRESTApiError(res)) { + setApplicationRequired(true); + return; + } + + return res; + } catch (e) { + // We need to throw here so the connector will be not be saved. + throw e; + } + }, [action, fetchAppInfo]); + const beforeActionConnectorSave = useCallback(async () => { if (!isLegacy) { - try { - const res = await fetchAppInfo(action); - if (isRESTApiError(res)) { - setApplicationRequired(true); - return; - } - } catch (e) { - // We need to throw here so the connector will be not be saved. - throw e; - } + await getApplicationInfo; } - }, [action, fetchAppInfo, isLegacy]); + }, [getApplicationInfo, isLegacy]); const afterActionConnectorSave = useCallback(async () => { // TODO: Implement @@ -98,9 +115,56 @@ const ServiceNowConnectorFields: React.FC< setCallbacks, ]); + const onMigrateClick = useCallback(() => setShowModal(true), []); + const onModalCancel = useCallback(() => setShowModal(false), []); + + const onModalConfirm = useCallback(async () => { + // TODO: Handle properly + if (hasErrorsOrEmptyFields) { + return; + } + + setShowModal(false); + await getApplicationInfo(); + await updateActionConnector({ + http, + connector: { + name: action.name, + config: { apiUrl, isLegacy: false }, + secrets: { username, password }, + }, + id: action.id, + }); + + editActionConfig('isLegacy', false); + toasts.addSuccess({ + title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), + text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, + }); + }, [ + hasErrorsOrEmptyFields, + getApplicationInfo, + http, + action.name, + action.id, + apiUrl, + username, + password, + editActionConfig, + toasts, + ]); + return ( <> + {showModal && ( + + )} {!isLegacy && } + {isLegacy && } - {isLegacy && } {applicationRequired && } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 799d7e25e657e..f26cd0359287f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -217,3 +217,21 @@ export const INSTALLATION_CALLOUT_TITLE = i18n.translate( defaultMessage: 'Elastic ServiceNow Application', } ); + +export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) => + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle', + { + defaultMessage: 'Migrated connector {connectorName}', + values: { + connectorName, + }, + } + ); + +export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutInfo', + { + defaultMessage: 'Connector has been successfully migrated.', + } +); From f4ca82327033be8d1462e51a41a747c3464b2931 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 31 Aug 2021 15:19:31 +0300 Subject: [PATCH 23/92] Change migration version to 7.16 --- .../saved_objects/actions_migrations.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index a79d4be9df5c1..987b3f8f0677e 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -62,17 +62,11 @@ export function getActionsMigrations( pipeMigrations(addIsMissingSecretsField) ); - const migrationActionsFifteen = createEsoMigration( + const migrationActionsSixteen = createEsoMigration( encryptedSavedObjects, (doc): doc is SavedObjectUnsanitizedDoc => - doc.attributes.actionTypeId === '.servicenow', - pipeMigrations(markOldServiceNowITSMConnectorAsLegacy) - ); - - const migrationEmailActionsSixteen = createEsoMigration( - encryptedSavedObjects, - (doc): doc is SavedObjectUnsanitizedDoc => doc.attributes.actionTypeId === '.email', - pipeMigrations(setServiceConfigIfNotSet) + doc.attributes.actionTypeId === '.servicenow' || doc.attributes.actionTypeId === '.email', + pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet) ); const migrationActions800 = createEsoMigration( @@ -86,8 +80,7 @@ export function getActionsMigrations( '7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'), '7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'), - '7.15.0': executeMigrationWithErrorHandling(migrationActionsFifteen, '7.15.0'), - '7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'), + '7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'), '8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'), }; } @@ -205,6 +198,10 @@ const addIsMissingSecretsField = ( const markOldServiceNowITSMConnectorAsLegacy = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { + if (doc.attributes.actionTypeId !== '.servicenow') { + return doc; + } + return { ...doc, attributes: { From 8651cc7c4aed8e0f331b2c9a3582a754f9e1b2a5 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 31 Aug 2021 15:35:02 +0300 Subject: [PATCH 24/92] Fix bug --- .../builtin_action_types/servicenow/servicenow_connectors.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 7a1347c758c68..3ebe355637c7d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -101,7 +101,7 @@ const ServiceNowConnectorFields: React.FC< const beforeActionConnectorSave = useCallback(async () => { if (!isLegacy) { - await getApplicationInfo; + await getApplicationInfo(); } }, [getApplicationInfo, isLegacy]); From 484156b2a9f5d45991d23721921b15e78eeadc4d Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 1 Sep 2021 11:15:58 +0300 Subject: [PATCH 25/92] Improve message --- .../servicenow/application_required_callout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 1323b92d8558d..71dc55d0d32fb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -22,7 +22,7 @@ const ApplicationRequiredCalloutComponent: React.FC = () => { 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout', { defaultMessage: - 'In order to create this connector, please go to the ServiceNow Store and install the Elastic application.', + 'Elastic application is required. Please go to the ServiceNow Store and install the Elastic application.', } )} /> From 1d1b4e7265f4fd77da559c1ca58b89ec78b89c5e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 3 Sep 2021 15:39:13 +0300 Subject: [PATCH 26/92] Use feature flag --- .../servicenow/service.ts | 4 ++- .../actions/server/constants/connectors.ts | 9 +++++++ .../configure_cases/connectors_dropdown.tsx | 4 ++- .../servicenow/servicenow_connectors.tsx | 8 +++--- .../components/actions_connectors_list.tsx | 27 ++++++++++--------- 5 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/actions/server/constants/connectors.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 5ddf592bdaa72..15030c5211f4a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -30,6 +30,7 @@ import { import { request, getErrorMessage, addTimeZoneToDate } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ServiceNowSIRActionTypeId } from './config'; +import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../constants/connectors'; const SYS_DICTIONARY = `api/now/table/sys_dictionary`; const IMPORTATION_SET_TABLE = 'x_elas2_inc_int_elastic_incident'; @@ -73,7 +74,8 @@ export const createExternalService = ( }); // TODO: Remove ServiceNow SIR check when there is a SN Store app for SIR. - const useOldApi = isLegacy || actionTypeId === ServiceNowSIRActionTypeId; + const useOldApi = + !ENABLE_NEW_SN_ITSM_CONNECTOR || isLegacy || actionTypeId === ServiceNowSIRActionTypeId; const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); const getUpdateIncidentUrl = (incidentId: string) => diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts new file mode 100644 index 0000000000000..1c5c8143ded11 --- /dev/null +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: Remove when ServiceNow Elastic Application is published. +export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 81f3f540c170a..21b9882b2ef4c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -14,6 +14,8 @@ import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; import { getConnectorIcon } from '../utils'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../actions/server/constants/connectors'; export interface Props { connectors: ActionConnector[]; @@ -89,7 +91,7 @@ const ConnectorsDropdownComponent: React.FC = ({ {connector.name} - {connector.config.isLegacy && ( + {ENABLE_NEW_SN_ITSM_CONNECTOR && connector.config.isLegacy && ( @@ -100,7 +102,7 @@ const ServiceNowConnectorFields: React.FC< }, [action, fetchAppInfo]); const beforeActionConnectorSave = useCallback(async () => { - if (!isLegacy) { + if (ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy) { await getApplicationInfo(); } }, [getApplicationInfo, isLegacy]); @@ -163,8 +165,8 @@ const ServiceNowConnectorFields: React.FC< hasErrors={hasErrorsOrEmptyFields} /> )} - {!isLegacy && } - {isLegacy && } + {ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy && } + {ENABLE_NEW_SN_ITSM_CONNECTOR && isLegacy && } { const { @@ -191,18 +193,19 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} - {(item as UserConfiguredActionConnector< - Record, - Record - >).config.isLegacy && ( - - )} + {ENABLE_NEW_SN_ITSM_CONNECTOR && + (item as UserConfiguredActionConnector< + Record, + Record + >).config.isLegacy && ( + + )} ); From 1c1ea4426da6438cf8c63974e6dd88ffb19efd42 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 7 Sep 2021 13:06:32 +0300 Subject: [PATCH 27/92] Create credentials component --- .../servicenow/credentials.tsx | 182 ++++++++++++++++++ .../migration_confirmation_modal.tsx | 3 + .../servicenow/servicenow_connectors.tsx | 150 ++------------- 3 files changed, 197 insertions(+), 138 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx new file mode 100644 index 0000000000000..a304a7a1f80b7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiFieldText, + EuiSpacer, + EuiTitle, + EuiFieldPassword, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { useKibana } from '../../../../common/lib/kibana'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; +import * as i18n from './translations'; +import { ServiceNowActionConnector } from './types'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; +} + +const CredentialsComponent: React.FC = ({ + action, + errors, + readOnly, + isLoading, + editActionSecrets, + editActionConfig, +}) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; + + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + [editActionConfig] + ); + + const handleOnChangeSecretConfig = useCallback( + (key: string, value: string) => editActionSecrets(key, value), + [editActionSecrets] + ); + + return ( + <> + + + + + + } + > + handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + disabled={isLoading} + /> + + + + + + + +

{i18n.AUTHENTICATION_LABEL}

+
+
+
+ + + + + {getEncryptedFieldNotifyLabel( + !action.id, + 2, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + + + + + + handleOnChangeSecretConfig('username', evt.target.value)} + onBlur={() => { + if (!username) { + editActionSecrets('username', ''); + } + }} + disabled={isLoading} + /> + + + + + + + + handleOnChangeSecretConfig('password', evt.target.value)} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + disabled={isLoading} + /> + + + + + ); +}; + +export const Credentials = memo(CredentialsComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx index ca7b37fd15e79..ab58b3d5e7d21 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx @@ -47,6 +47,9 @@ const modalErrorMessage = i18n.translate( interface Props { hasErrors?: boolean; + url: string; + username: string; + password: string; onCancel: () => void; onConfirm: () => void; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 64afad0e47937..611c4a6a8426b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -7,24 +7,11 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldPassword, - EuiSpacer, - EuiLink, - EuiTitle, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; import { useKibana } from '../../../../common/lib/kibana'; -import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; import { DeprecatedCallout } from './deprecated_callout'; import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; @@ -34,6 +21,7 @@ import { MigrationConfirmationModal } from './migration_confirmation_modal'; import { updateActionConnector } from '../../../lib/action_connector_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../../../actions/server/constants/connectors'; +import { Credentials } from './credentials'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps @@ -49,7 +37,6 @@ const ServiceNowConnectorFields: React.FC< }) => { const { http, - docLinks, notifications: { toasts }, } = useKibana().services; const { apiUrl, isLegacy } = action.config; @@ -72,16 +59,6 @@ const ServiceNowConnectorFields: React.FC< const [showModal, setShowModal] = useState(false); - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); - - const handleOnChangeSecretConfig = useCallback( - (key: string, value: string) => editActionSecrets(key, value), - [editActionSecrets] - ); - const { fetchAppInfo, isLoading } = useGetAppInfo({ toastNotifications: toasts }); const [applicationRequired, setApplicationRequired] = useState(false); @@ -160,6 +137,9 @@ const ServiceNowConnectorFields: React.FC< <> {showModal && ( } {ENABLE_NEW_SN_ITSM_CONNECTOR && isLegacy && } - - - - - - } - > - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - disabled={isLoading} - /> - - - - - - - -

{i18n.AUTHENTICATION_LABEL}

-
-
-
- - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - disabled={isLoading} - /> - - - - - - - - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - disabled={isLoading} - /> - - - + {applicationRequired && } ); From bbcdf036f5a63c02db50c08353b6c57fa3e43948 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 7 Sep 2021 14:09:28 +0300 Subject: [PATCH 28/92] Add form to migration modal --- .../servicenow/credentials.tsx | 10 +- .../servicenow/helpers.ts | 6 ++ .../migration_confirmation_modal.tsx | 100 +++++++++++++----- .../servicenow/servicenow_connectors.tsx | 34 ++---- 4 files changed, 90 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx index a304a7a1f80b7..55f9861a45b7b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx @@ -22,6 +22,7 @@ import { useKibana } from '../../../../common/lib/kibana'; import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; interface Props { action: ActionConnectorFieldsProps['action']; @@ -44,12 +45,9 @@ const CredentialsComponent: React.FC = ({ const { apiUrl } = action.config; const { username, password } = action.secrets; - const isApiUrlInvalid: boolean = - errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; - const isUsernameInvalid: boolean = - errors.username !== undefined && errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = - errors.password !== undefined && errors.password.length > 0 && password !== undefined; + const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); + const isUsernameInvalid = isFieldInvalid(username, errors.username); + const isPasswordInvalid = isFieldInvalid(password, errors.password); const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index 8ce1f969bbb72..a1dd6bb08a369 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -6,6 +6,7 @@ */ import { EuiSelectOption } from '@elastic/eui'; +import { IErrorObject } from '../../../../../public/types'; import { AppInfo, Choice, RESTApiError } from './types'; export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => @@ -13,3 +14,8 @@ export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError => (res as RESTApiError).error != null || (res as RESTApiError).status === 'failure'; + +export const isFieldInvalid = ( + field: string | undefined, + error: string | IErrorObject | string[] +): boolean => error !== undefined && error.length > 0 && field !== undefined; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx index ab58b3d5e7d21..e2aeec90599c0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx @@ -6,13 +6,27 @@ */ import React, { memo } from 'react'; -import { EuiConfirmModal, EuiTextColor } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { ServiceNowActionConnector } from './types'; +import { Credentials } from './credentials'; +import { isFieldInvalid } from './helpers'; const title = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', { - defaultMessage: 'ServiceNow connector migration', + defaultMessage: 'Migrate ServiceNow connector', } ); @@ -34,45 +48,75 @@ const modalText = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalText', { defaultMessage: - 'You are about to migrate to the new connector. This action cannot be reversed.', - } -); - -const modalErrorMessage = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalErrorMessage', - { - defaultMessage: 'Invalid configuration or secrets', + 'This action migrates the current connector to the new one. The action cannot be reversed.', } ); interface Props { - hasErrors?: boolean; - url: string; - username: string; - password: string; + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; onCancel: () => void; onConfirm: () => void; } const MigrationConfirmationModalComponent: React.FC = ({ + action, + errors, + readOnly, + isLoading, + editActionSecrets, + editActionConfig, onCancel, onConfirm, - hasErrors = true, }) => { + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const hasErrorsOrEmptyFields = + apiUrl === undefined || + username === undefined || + password === undefined || + isFieldInvalid(apiUrl, errors.apiUrl) || + isFieldInvalid(username, errors.username) || + isFieldInvalid(password, errors.password); + return ( - -

{modalText}

-

{hasErrors && {modalErrorMessage}}

-
+ + + +

{title}

+
+
+ + + {modalText} + + + + + {cancelButtonText} + + {confirmButtonText} + + +
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 611c4a6a8426b..488cdbb7a264f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -40,22 +40,7 @@ const ServiceNowConnectorFields: React.FC< notifications: { toasts }, } = useKibana().services; const { apiUrl, isLegacy } = action.config; - - const isApiUrlInvalid: boolean = - errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; const { username, password } = action.secrets; - const isUsernameInvalid: boolean = - errors.username !== undefined && errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = - errors.password !== undefined && errors.password.length > 0 && password !== undefined; - - const hasErrorsOrEmptyFields = - apiUrl === undefined || - username === undefined || - password === undefined || - isApiUrlInvalid || - isUsernameInvalid || - isPasswordInvalid; const [showModal, setShowModal] = useState(false); @@ -98,12 +83,6 @@ const ServiceNowConnectorFields: React.FC< const onModalCancel = useCallback(() => setShowModal(false), []); const onModalConfirm = useCallback(async () => { - // TODO: Handle properly - if (hasErrorsOrEmptyFields) { - return; - } - - setShowModal(false); await getApplicationInfo(); await updateActionConnector({ http, @@ -116,12 +95,13 @@ const ServiceNowConnectorFields: React.FC< }); editActionConfig('isLegacy', false); + setShowModal(false); + toasts.addSuccess({ title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, }); }, [ - hasErrorsOrEmptyFields, getApplicationInfo, http, action.name, @@ -137,12 +117,14 @@ const ServiceNowConnectorFields: React.FC< <> {showModal && ( )} {ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy && } From 569c5fc878dd76f37e1c815cf96c120a95b8daaf Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 16 Sep 2021 15:37:26 +0300 Subject: [PATCH 29/92] Improve installation callout --- .../servicenow/installation_callout.tsx | 22 +++++++------------ .../servicenow/translations.ts | 10 ++++++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx index bd824a2fb4158..f1ef72cdba500 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx @@ -6,32 +6,26 @@ */ import React, { memo } from 'react'; -import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiCallOut, EuiButton } from '@elastic/eui'; import * as i18n from './translations'; +const STORE_URL = 'https://store.servicenow.com/'; + const InstallationCalloutComponent: React.FC = () => { return ( <> - - {i18n.INSTALL} - - ), - }} - /> + + {i18n.VISIT_SN_STORE} + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index f26cd0359287f..6ee1c7449a7f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -214,7 +214,8 @@ export const INSTALL = i18n.translate( export const INSTALLATION_CALLOUT_TITLE = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutInfo', { - defaultMessage: 'Elastic ServiceNow Application', + defaultMessage: + 'To use this connector, you must first install the Elastic App from the ServiceNow App Store', } ); @@ -235,3 +236,10 @@ export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( defaultMessage: 'Connector has been successfully migrated.', } ); + +export const VISIT_SN_STORE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.visitSNStore', + { + defaultMessage: 'Visit ServiceNow app store', + } +); From d29e4a7b5764c29670c2235dd15a507bfcb10e90 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 16 Sep 2021 15:37:41 +0300 Subject: [PATCH 30/92] Improve deprecated callout --- .../servicenow/deprecated_callout.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx index 247b23758cdc6..101d1572a67ad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -20,18 +20,18 @@ const DeprecatedCalloutComponent: React.FC = ({ onMigrate }) => { = ({ onMigrate }) => { {i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate', { - defaultMessage: 'migrate', + defaultMessage: 'update this connector.', } )} From 9354925b4dbb9d9c1f30e1f1ab2f411bdb318ecb Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 16 Sep 2021 15:53:10 +0300 Subject: [PATCH 31/92] Improve modal --- .../migration_confirmation_modal.tsx | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx index e2aeec90599c0..a43fea4dd1f4e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/migration_confirmation_modal.tsx @@ -16,6 +16,9 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, + EuiCallOut, + EuiTextColor, + EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorFieldsProps } from '../../../../../public/types'; @@ -26,29 +29,36 @@ import { isFieldInvalid } from './helpers'; const title = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', { - defaultMessage: 'Migrate ServiceNow connector', + defaultMessage: 'Update ServiceNow connector', } ); const cancelButtonText = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', { - defaultMessage: 'Do not migrate', + defaultMessage: 'Cancel', } ); const confirmButtonText = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', { - defaultMessage: 'Migrate', + defaultMessage: 'Update', } ); -const modalText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalText', +const calloutTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle', { defaultMessage: - 'This action migrates the current connector to the new one. The action cannot be reversed.', + 'The Elastic App from the ServiceNow App Store must be installed prior to running the update.', + } +); + +const warningMessage = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage', + { + defaultMessage: 'This will update all instances of this connector. This can not be reversed.', } ); @@ -93,7 +103,14 @@ const MigrationConfirmationModalComponent: React.FC = ({ - {modalText} + + + = ({ editActionSecrets={editActionSecrets} editActionConfig={editActionConfig} /> + + + + {warningMessage} + + {cancelButtonText} From 79c56cbc06dcccccd4a4c64351a5981fecaa58ec Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 16 Sep 2021 16:04:07 +0300 Subject: [PATCH 32/92] Improve application required modal --- .../application_required_callout.tsx | 18 ++++++++++--- .../servicenow/installation_callout.tsx | 9 +++---- .../servicenow/sn_store_button.tsx | 27 +++++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 71dc55d0d32fb..2d26f8b021efa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -8,24 +8,34 @@ import React, { memo } from 'react'; import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { SNStoreButton } from './sn_store_button'; + +const content = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.content', + { + defaultMessage: 'Please go to the ServiceNow app store and install the application', + } +); const ApplicationRequiredCalloutComponent: React.FC = () => { return ( <> + > +

{content}

+ +
); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx index f1ef72cdba500..064207910568f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.tsx @@ -6,11 +6,10 @@ */ import React, { memo } from 'react'; -import { EuiSpacer, EuiCallOut, EuiButton } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import * as i18n from './translations'; - -const STORE_URL = 'https://store.servicenow.com/'; +import { SNStoreButton } from './sn_store_button'; const InstallationCalloutComponent: React.FC = () => { return ( @@ -23,9 +22,7 @@ const InstallationCalloutComponent: React.FC = () => { data-test-subj="snInstallationCallout" title={i18n.INSTALLATION_CALLOUT_TITLE} > - - {i18n.VISIT_SN_STORE} - +
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx new file mode 100644 index 0000000000000..5921f679d3f50 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiButtonProps, EuiButton } from '@elastic/eui'; + +import * as i18n from './translations'; + +const STORE_URL = 'https://store.servicenow.com/'; + +interface Props { + color: EuiButtonProps['color']; +} + +const SNStoreButtonComponent: React.FC = ({ color }) => { + return ( + + {i18n.VISIT_SN_STORE} + + ); +}; + +export const SNStoreButton = memo(SNStoreButtonComponent); From 24a6d219bc92129d1153e817132824fd7fee7de0 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 16 Sep 2021 16:24:23 +0300 Subject: [PATCH 33/92] Improve SN form --- .../servicenow/credentials.tsx | 29 +++++++++++++------ .../servicenow/translations.ts | 25 ++++++++++++++-- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx index 55f9861a45b7b..caee946524265 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials.tsx @@ -61,7 +61,25 @@ const CredentialsComponent: React.FC = ({ return ( <> - + + + +

{i18n.SN_INSTANCE_LABEL}

+
+

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

+
= ({ error={errors.apiUrl} isInvalid={isApiUrlInvalid} label={i18n.API_URL_LABEL} - helpText={ - - - - } + helpText={i18n.API_URL_HELPTEXT} > Date: Sat, 18 Sep 2021 14:21:00 +0300 Subject: [PATCH 34/92] Support both connectors --- .../builtin_action_types/servicenow/config.ts | 23 ++++++++++++++ .../builtin_action_types/servicenow/index.ts | 5 +-- .../servicenow/service.ts | 31 ++++++++----------- .../builtin_action_types/servicenow/types.ts | 9 ++++++ .../actions/server/constants/connectors.ts | 5 ++- .../builtin_action_types/servicenow/api.ts | 9 ++++-- .../servicenow/servicenow_connectors.tsx | 5 ++- .../servicenow/use_get_app_info.tsx | 9 ++++-- 8 files changed, 70 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts index c4d26ebed58c4..81e0afcd41f47 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -5,8 +5,31 @@ * 2.0. */ +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, +} from '../../constants/connectors'; +import { SNProductsConfig } from './types'; + export const serviceNowITSMTable = 'incident'; export const serviceNowSIRTable = 'sn_si_incident'; export const ServiceNowITSMActionTypeId = '.servicenow'; export const ServiceNowSIRActionTypeId = '.servicenow-sir'; + +export const snExternalServiceConfig: SNProductsConfig = { + '.servicenow': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR, + }, + '.servicenow-sir': { + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, + }, +}; + +export const FIELD_PREFIX = 'u_'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index c02d2477927f0..81ab051a8af72 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -36,6 +36,7 @@ import { serviceNowITSMTable, ServiceNowSIRActionTypeId, serviceNowSIRTable, + snExternalServiceConfig, } from './config'; export { @@ -138,17 +139,17 @@ async function executor( ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; const externalService = createExternalService( - table, { config, secrets, }, logger, configurationUtilities, - actionTypeId + externalServiceConfig ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 15030c5211f4a..5eb1c4fcdd428 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -18,6 +18,7 @@ import { Incident, GetApplicationInfoResponse, PartialIncident, + SNProductsConfigValue, } from './types'; import * as i18n from './translations'; @@ -29,12 +30,7 @@ import { } from './types'; import { request, getErrorMessage, addTimeZoneToDate } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { ServiceNowSIRActionTypeId } from './config'; -import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../constants/connectors'; - -const SYS_DICTIONARY = `api/now/table/sys_dictionary`; -const IMPORTATION_SET_TABLE = 'x_elas2_inc_int_elastic_incident'; -const FIELD_PREFIX = 'u_'; +import { FIELD_PREFIX } from './config'; const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => useOldApi @@ -44,12 +40,13 @@ const prepareIncident = (useOldApi: boolean, incident: PartialIncident): Partial {} as Incident ); +export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; + export const createExternalService = ( - table: string, { config, secrets }: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, - actionTypeId: string + { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue ): ExternalService => { const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; @@ -59,23 +56,21 @@ export const createExternalService = ( } const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; - const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${IMPORTATION_SET_TABLE}`; - const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/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 importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`; + const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`; const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`; /** * Need to be set the same at: * x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts */ - const getVersionUrl = `${urlWithoutTrailingSlash}/api/x_elas2_inc_int/elastic_api/health`; + const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`; const axiosInstance = axios.create({ auth: { username, password }, }); - // TODO: Remove ServiceNow SIR check when there is a SN Store app for SIR. - const useOldApi = - !ENABLE_NEW_SN_ITSM_CONNECTOR || isLegacy || actionTypeId === ServiceNowSIRActionTypeId; + const useOldApi = !useImportAPI || isLegacy; const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); const getUpdateIncidentUrl = (incidentId: string) => @@ -138,7 +133,7 @@ export const createExternalService = ( try { const res = await request({ axios: axiosInstance, - url: getVersionUrl, + url: getVersionUrl(), logger, configurationUtilities, }); @@ -263,8 +258,8 @@ export const createExternalService = ( logger, data: { ...prepareIncident(useOldApi, incident), - // u_incident_id is used to update the incident when using the Import Set API. - ...(useOldApi ? {} : { u_incident_id: incidentId }), + // elastic_incident_id is used to update the incident when using the Import Set API. + ...(useOldApi ? {} : { elastic_incident_id: incidentId }), }, configurationUtilities, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index b14728b753853..ed62df71430fd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -229,3 +229,12 @@ export interface GetApplicationInfoResponse { scope: string; version: string; } + +export interface SNProductsConfigValue { + table: string; + appScope: string; + useImportAPI: boolean; + importSetTable: string; +} + +export type SNProductsConfig = Record; diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts index 1c5c8143ded11..f20d499716cf0 100644 --- a/x-pack/plugins/actions/server/constants/connectors.ts +++ b/x-pack/plugins/actions/server/constants/connectors.ts @@ -5,5 +5,8 @@ * 2.0. */ -// TODO: Remove when ServiceNow Elastic Application is published. +// TODO: Remove when Elastic for ITSM is published. export const ENABLE_NEW_SN_ITSM_CONNECTOR = true; + +// TODO: Remove when Elastic for Security Operations is published. +export const ENABLE_NEW_SN_SIR_CONNECTOR = true; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 86b930be6f193..75b9b8cb306d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -6,6 +6,8 @@ */ import { HttpSetup } from 'kibana/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config'; import { BASE_ACTION_API_PATH } from '../../../constants'; import { AppInfo, RESTApiError } from './types'; @@ -35,21 +37,24 @@ export async function getChoices({ * The app info url should be the same as at: * x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts */ -const getAppInfoUrl = (url: string) => `${url}/api/x_elas2_inc_int/elastic_api/health`; +const getAppInfoUrl = (url: string, scope: string) => `${url}/api/${scope}/elastic_api/health`; export async function getAppInfo({ signal, apiUrl, username, password, + actionTypeId, }: { signal: AbortSignal; apiUrl: string; username: string; password: string; + actionTypeId: string; }): Promise { const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; - const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash), { + const config = snExternalServiceConfig[actionTypeId]; + const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash, config.appScope ?? ''), { method: 'GET', signal, headers: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 488cdbb7a264f..93787e513a3f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -44,7 +44,10 @@ const ServiceNowConnectorFields: React.FC< const [showModal, setShowModal] = useState(false); - const { fetchAppInfo, isLoading } = useGetAppInfo({ toastNotifications: toasts }); + const { fetchAppInfo, isLoading } = useGetAppInfo({ + toastNotifications: toasts, + actionTypeId: action.actionTypeId, + }); const [applicationRequired, setApplicationRequired] = useState(false); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx index c3551f70a7998..2623b931d117e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -12,6 +12,7 @@ import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; import * as i18n from './translations'; export interface UseGetChoicesProps { + actionTypeId: string; toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' @@ -23,7 +24,10 @@ export interface UseGetChoices { isLoading: boolean; } -export const useGetAppInfo = ({ toastNotifications }: UseGetChoicesProps): UseGetChoices => { +export const useGetAppInfo = ({ + actionTypeId, + toastNotifications, +}: UseGetChoicesProps): UseGetChoices => { const [isLoading, setIsLoading] = useState(false); const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); @@ -41,6 +45,7 @@ export const useGetAppInfo = ({ toastNotifications }: UseGetChoicesProps): UseGe apiUrl: connector.config.apiUrl, username: connector.secrets.username, password: connector.secrets.password, + actionTypeId, }); if (!didCancel.current) { @@ -59,7 +64,7 @@ export const useGetAppInfo = ({ toastNotifications }: UseGetChoicesProps): UseGe throw error; } }, - [toastNotifications] + [actionTypeId, toastNotifications] ); useEffect(() => { From 8086ce1c51a8dd610404a3daa4b8fc9c5f1d2b59 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sat, 18 Sep 2021 14:21:36 +0300 Subject: [PATCH 35/92] Support correlation attributes --- .../actions/server/builtin_action_types/servicenow/schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 90279c8e1ea9e..d67c22ac5238d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -40,6 +40,8 @@ const CommonAttributes = { externalId: schema.nullable(schema.string()), category: schema.nullable(schema.string()), subcategory: schema.nullable(schema.string()), + correlation_id: schema.nullable(schema.string()), + correlation_display: schema.nullable(schema.string()), }; // Schema for ServiceNow Incident Management (ITSM) From c05a4cb0a0a6cfbdba70b9484a2f764744054b1e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sat, 18 Sep 2021 18:17:44 +0300 Subject: [PATCH 36/92] Use same component for SIR --- .../servicenow/servicenow.tsx | 2 +- .../servicenow/servicenow_connectors_sir.tsx | 172 ------------------ .../servicenow/translations.ts | 2 +- 3 files changed, 2 insertions(+), 174 deletions(-) delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index d9e25916a76b3..bb4a645f10bbc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -137,7 +137,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel< selectMessage: SERVICENOW_SIR_DESC, actionTypeTitle: SERVICENOW_SIR_TITLE, validateConnector, - actionConnectorFields: lazy(() => import('./servicenow_connectors_sir')), + actionConnectorFields: lazy(() => import('./servicenow_connectors')), validateParams: async ( actionParams: ServiceNowSIRActionParams ): Promise> => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx deleted file mode 100644 index ef354f63f8ca7..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors_sir.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback } from 'react'; - -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldPassword, - EuiSpacer, - EuiLink, - EuiTitle, -} from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; -import { ActionConnectorFieldsProps } from '../../../../types'; - -import * as i18n from './translations'; -import { ServiceNowActionConnector } from './types'; -import { useKibana } from '../../../../common/lib/kibana'; -import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; - -// TODO: Remove when SN SIR has its own SN Store application. - -const ServiceNowConnectorSIRFields: React.FC< - ActionConnectorFieldsProps -> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { - const { docLinks } = useKibana().services; - const { apiUrl } = action.config; - - const isApiUrlInvalid: boolean = - errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; - - const { username, password } = action.secrets; - - const isUsernameInvalid: boolean = - errors.username !== undefined && errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = - errors.password !== undefined && errors.password.length > 0 && password !== undefined; - - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); - - const handleOnChangeSecretConfig = useCallback( - (key: string, value: string) => editActionSecrets(key, value), - [editActionSecrets] - ); - return ( - <> - - - - - - } - > - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - /> - - - - - - - -

{i18n.AUTHENTICATION_LABEL}

-
-
-
- - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - /> - - - - - - - - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - /> - - - - - ); -}; - -// eslint-disable-next-line import/no-default-export -export { ServiceNowConnectorSIRFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 2ec8ac282baf8..b438d674403a7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -207,7 +207,7 @@ export const PRIORITY_LABEL = i18n.translate( export const APP_INFO_API_ERROR = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetAppInfoMessage', { - defaultMessage: 'Unreachable Elastic Application in ServiceNow instance.', + defaultMessage: 'Unable to get application information.', } ); From ec7bfb758bfc26039c5cbc2360093fa15e9bacb4 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sat, 18 Sep 2021 18:43:27 +0300 Subject: [PATCH 37/92] Prevent using legacy connectors when creating a case --- .../servicenow/deprecated_callout.tsx | 26 +++++++++++++++++++ .../servicenow_itsm_case_fields.tsx | 21 ++++++++++----- .../servicenow/servicenow_sir_case_fields.tsx | 22 +++++++++++----- .../connectors/servicenow/translations.ts | 15 +++++++++++ .../connectors/servicenow/validator.ts | 26 +++++++++++++++++++ .../plugins/cases/public/components/utils.ts | 3 +++ 6 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx new file mode 100644 index 0000000000000..34c6a9547a090 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +import * as i18n from './translations'; + +const DeprecatedCalloutComponent: React.FC = () => { + return ( + + {i18n.LEGACY_CONNECTOR_WARNING_DESC} + + ); +}; + +export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 53c0d32dea1a5..ab7baa30590ab 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -16,6 +16,8 @@ import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; +import { connectorValidator } from './validator'; +import { DeprecatedCallout } from './deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -39,6 +41,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); + const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); @@ -151,6 +154,9 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< return isEdit ? (
+ + {showMappingWarning && } +
) : ( - + <> + {showMappingWarning && } + + ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 1f9a7cf7acd64..6c3018a554d7b 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -17,6 +17,8 @@ import { Choice, Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; +import { connectorValidator } from './validator'; +import { DeprecatedCallout } from './deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -40,8 +42,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; - const [choices, setChoices] = useState(defaultFields); + const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const onChangeCb = useCallback( ( @@ -168,6 +170,9 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< return isEdit ? (
+ + {showMappingWarning && } + @@ -269,12 +274,15 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
) : ( - + <> + {showMappingWarning && } + + ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts index fc48ecf17f2c6..5e4fdc4bfa2f9 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -73,3 +73,18 @@ export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( defaultMessage: 'Yes', } ); + +export const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', + { + defaultMessage: 'Deprecated connector type', + } +); + +export const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', + { + defaultMessage: + 'This connector type is deprecated. Create a new connector or update this connector', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts new file mode 100644 index 0000000000000..3f67f25549343 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ValidationConfig } from '../../../common/shared_imports'; +import { CaseActionConnector } from '../../types'; + +/** + * The user can not use a legacy connector + */ + +export const connectorValidator = ( + connector: CaseActionConnector +): ReturnType => { + const { + config: { isLegacy }, + } = connector; + if (isLegacy) { + return { + message: 'Deprecated connector', + }; + } +}; diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 5f7480cb84f7c..e5d69a3fb3e59 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -10,6 +10,7 @@ import { ConnectorTypes } from '../../common'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; import { StartPlugins } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; +import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; export const getConnectorById = ( @@ -22,6 +23,8 @@ const validators: Record< (connector: CaseActionConnector) => ReturnType > = { [ConnectorTypes.swimlane]: swimlaneConnectorValidator, + [ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator, + [ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator, }; export const getConnectorsFormValidators = ({ From 729c8e99d40fa2ad638a0069ce14232d5fca11c8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 21 Sep 2021 14:23:32 +0300 Subject: [PATCH 38/92] Add observables --- .../builtin_action_types/servicenow/api.ts | 4 +- .../servicenow/api_sir.ts | 69 ++++++++++ .../builtin_action_types/servicenow/config.ts | 2 + .../builtin_action_types/servicenow/index.ts | 26 ++-- .../builtin_action_types/servicenow/schema.ts | 20 ++- .../servicenow/service.ts | 125 +++++------------- .../servicenow/service_sir.ts | 104 +++++++++++++++ .../builtin_action_types/servicenow/types.ts | 42 +++++- .../builtin_action_types/servicenow/utils.ts | 45 +++++++ 9 files changed, 326 insertions(+), 111 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 958c1375c00b3..88cdfd069cf1b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -6,7 +6,7 @@ */ import { - ExternalServiceApi, + ExternalServiceAPI, GetChoicesHandlerArgs, GetChoicesResponse, GetCommonFieldsHandlerArgs, @@ -89,7 +89,7 @@ const getChoicesHandler = async ({ return res; }; -export const api: ExternalServiceApi = { +export const api: ExternalServiceAPI = { getChoices: getChoicesHandler, getFields: getFieldsHandler, getIncident: getIncidentHandler, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts new file mode 100644 index 0000000000000..b4a6d3f127b84 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ExecutorSubActionPushParamsSIR, + ExternalServiceAPI, + ExternalServiceSIR, + ObservableTypes, + PushToServiceApiHandlerArgs, + PushToServiceResponse, +} from './types'; + +import { api } from './api'; + +const formatObservables = (observables: string | string[], type: ObservableTypes) => { + /** + * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. + * Before the application the observables were being sent to ServiceNow as a concatenated string with + * delimiter. With the application the format changed to an array of observables. + */ + const obsAsArray = Array.isArray(observables) ? observables : observables.split(/[ ,|\r\n\t]+/); + return obsAsArray.map((obs) => ({ value: obs, type })); +}; + +const pushToServiceHandler = async ({ + externalService, + params, + secrets, + commentFieldKey, + logger, +}: PushToServiceApiHandlerArgs): Promise => { + const res = await api.pushToService({ + externalService, + params, + secrets, + commentFieldKey, + logger, + }); + + const { + incident: { + dest_ip: destIP, + malware_hash: malwareHash, + malware_url: malwareUrl, + source_ip: sourceIP, + }, + } = params as ExecutorSubActionPushParamsSIR; + const sirExternalService = externalService as ExternalServiceSIR; + + const obsWithType: Array<[string | string[], ObservableTypes]> = [ + [destIP ?? [], ObservableTypes.ip4], + [sourceIP ?? [], ObservableTypes.ip4], + [malwareHash ?? [], ObservableTypes.sha256], + [malwareUrl ?? [], ObservableTypes.url], + ]; + + const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); + await sirExternalService.bulkAddObservableToIncident(observables, res.id); + return res; +}; + +export const apiSIR: ExternalServiceAPI = { + ...api, + pushToService: pushToServiceHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts index 81e0afcd41f47..37e4c6994b403 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -23,12 +23,14 @@ export const snExternalServiceConfig: SNProductsConfig = { appScope: 'x_elas2_inc_int', table: 'incident', useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR, + commentFieldKey: 'work_notes', }, '.servicenow-sir': { importSetTable: 'x_elas2_sir_int_elastic_si_incident', appScope: 'x_elas2_sir_int', table: 'sn_si_incident', useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR, + commentFieldKey: 'work_notes', }, }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 81ab051a8af72..eb5f9fca14965 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -18,7 +18,7 @@ import { import { ActionsConfigurationUtilities } from '../../actions_config'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; import { createExternalService } from './service'; -import { api } from './api'; +import { api as commonAPI } from './api'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; import { @@ -30,6 +30,8 @@ import { ExecutorSubActionCommonFieldsParams, ServiceNowExecutorResultData, ExecutorSubActionGetChoicesParams, + ServiceFactory, + ExternalServiceAPI, } from './types'; import { ServiceNowITSMActionTypeId, @@ -38,6 +40,8 @@ import { serviceNowSIRTable, snExternalServiceConfig, } from './config'; +import { createExternalServiceSIR } from './service_sir'; +import { apiSIR } from './api_sir'; export { ServiceNowITSMActionTypeId, @@ -87,9 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowITSMTable, - commentFieldKey: 'work_notes', actionTypeId: ServiceNowITSMActionTypeId, + createService: createExternalService, + api: commonAPI, }), }; } @@ -112,9 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service executor: curry(executor)({ logger, configurationUtilities, - table: serviceNowSIRTable, - commentFieldKey: 'work_notes', actionTypeId: ServiceNowSIRActionTypeId, + createService: createExternalServiceSIR, + api: apiSIR, }), }; } @@ -125,15 +129,15 @@ async function executor( { logger, configurationUtilities, - table, actionTypeId, - commentFieldKey = 'comments', + createService, + api, }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; - table: string; actionTypeId: string; - commentFieldKey?: string; + createService: ServiceFactory; + api: ExternalServiceAPI; }, execOptions: ServiceNowActionTypeExecutorOptions ): Promise> { @@ -142,7 +146,7 @@ async function executor( const externalServiceConfig = snExternalServiceConfig[actionTypeId]; let data: ServiceNowExecutorResultData | null = null; - const externalService = createExternalService( + const externalService = createService( { config, secrets, @@ -171,7 +175,7 @@ async function executor( params: pushToServiceParams, secrets, logger, - commentFieldKey, + commentFieldKey: externalServiceConfig.commentFieldKey, }); logger.debug(`response push to service for incident id: ${data.id}`); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index d67c22ac5238d..eb6f33810c0e7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -59,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - dest_ip: schema.nullable(schema.string()), - malware_hash: schema.nullable(schema.string()), - malware_url: schema.nullable(schema.string()), - source_ip: schema.nullable(schema.string()), + dest_ip: schema.oneOf([ + schema.nullable(schema.string()), + schema.nullable(schema.arrayOf(schema.string())), + ]), + malware_hash: schema.oneOf([ + schema.nullable(schema.string()), + schema.nullable(schema.arrayOf(schema.string())), + ]), + malware_url: schema.oneOf([ + schema.nullable(schema.string()), + schema.nullable(schema.arrayOf(schema.string())), + ]), + source_ip: schema.oneOf([ + schema.nullable(schema.string()), + schema.nullable(schema.arrayOf(schema.string())), + ]), priority: schema.nullable(schema.string()), }), comments: CommentsSchema, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 5eb1c4fcdd428..bf76123c7cf76 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -15,34 +15,21 @@ import { ImportSetApiResponse, ImportSetApiResponseError, ServiceNowIncident, - Incident, GetApplicationInfoResponse, - PartialIncident, SNProductsConfigValue, + ServiceFactory, } from './types'; import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; -import { - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ResponseError, -} from './types'; -import { request, getErrorMessage, addTimeZoneToDate } from '../lib/axios_utils'; +import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { FIELD_PREFIX } from './config'; - -const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => - useOldApi - ? incident - : Object.entries(incident).reduce( - (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), - {} as Incident - ); +import { createServiceError, getPushedDate, prepareIncident } from './utils'; export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; -export const createExternalService = ( +export const createExternalService: ServiceFactory = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, configurationUtilities: ActionsConfigurationUtilities, @@ -99,15 +86,6 @@ export const createExternalService = ( } }; - const createErrorMessage = (errorResponse: ResponseError): string => { - if (errorResponse == null) { - return 'unknown'; - } - - const { error } = errorResponse; - return error != null ? `${error?.message}: ${error?.detail}` : 'unknown'; - }; - const isImportSetApiResponseAnError = ( data: ImportSetApiResponse['result'][0] ): data is ImportSetApiResponseError['result'][0] => data.status === 'error'; @@ -142,14 +120,17 @@ export const createExternalService = ( return { ...res.data.result }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get application version. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get application version'); + } + }; + + const logApplicationInfo = (scope: string, version: string) => + logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`); + + const checkIfApplicationIsInstalled = async () => { + if (!useOldApi) { + const { version, scope } = await getApplicationInformation(); + logApplicationInfo(scope, version); } }; @@ -166,14 +147,7 @@ export const createExternalService = ( return { ...res.data.result }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get incident with id ${id}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, `Unable to get incident with id ${id}`); } }; @@ -189,23 +163,15 @@ export const createExternalService = ( checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to find incidents by query'); } }; + const getUrl = () => urlWithoutTrailingSlash; + const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { - if (!useOldApi) { - const { version } = await getApplicationInformation(); - logger.debug(`Create incident: Current elastic application version: ${version}`); - } + await checkIfApplicationIsInstalled(); const res = await request({ axios: axiosInstance, @@ -228,27 +194,17 @@ export const createExternalService = ( return { title: insertedIncident.number, id: insertedIncident.sys_id, - pushedDate: new Date(addTimeZoneToDate(insertedIncident.sys_created_on)).toISOString(), + pushedDate: getPushedDate(insertedIncident.sys_created_on), url: getIncidentViewURL(insertedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to create incident'); } }; const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { - if (!useOldApi) { - const { version } = await getApplicationInformation(); - logger.debug(`Create incident: Current elastic application version: ${version}`); - } + await checkIfApplicationIsInstalled(); const res = await request({ axios: axiosInstance, @@ -276,18 +232,11 @@ export const createExternalService = ( return { title: updatedIncident.number, id: updatedIncident.sys_id, - pushedDate: new Date(addTimeZoneToDate(updatedIncident.sys_updated_on)).toISOString(), + pushedDate: getPushedDate(updatedIncident.sys_updated_on), url: getIncidentViewURL(updatedIncident.sys_id), }; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to update incident with id ${incidentId}. Error: ${ - error.message - } Reason: ${createErrorMessage(error.response?.data)}` - ) - ); + throw createServiceError(error, `Unable to update incident with id ${incidentId}`); } }; @@ -304,14 +253,7 @@ export const createExternalService = ( return res.data.result.length > 0 ? res.data.result : []; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get fields'); } }; @@ -326,14 +268,7 @@ export const createExternalService = ( checkInstance(res); return res.data.result; } catch (error) { - throw new Error( - getErrorMessage( - i18n.SERVICENOW, - `Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage( - error.response?.data - )}` - ) - ); + throw createServiceError(error, 'Unable to get choices'); } }; @@ -344,5 +279,9 @@ export const createExternalService = ( getIncident, updateIncident, getChoices, + getUrl, + checkInstance, + getApplicationInformation, + checkIfApplicationIsInstalled, }; }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts new file mode 100644 index 0000000000000..8f3e27b355c0e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { + ExternalServiceCredentials, + SNProductsConfigValue, + Observable, + ExternalServiceSIR, + ObservableResponse, + ServiceFactory, +} from './types'; + +import { Logger } from '../../../../../../src/core/server'; +import { ServiceNowSecretConfigurationType } from './types'; +import { request } from '../lib/axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { createExternalService } from './service'; +import { createServiceError } from './utils'; + +const getAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`; + +const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => + `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; + +export const createExternalServiceSIR: ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +): ExternalServiceSIR => { + const snService = createExternalService( + credentials, + logger, + configurationUtilities, + serviceConfig + ); + + const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const _addObservable = async (data: Observable | Observable[], url: string) => { + snService.checkIfApplicationIsInstalled(); + + const res = await request({ + axios: axiosInstance, + url, + logger, + method: 'post', + data, + configurationUtilities, + }); + + snService.checkInstance(res); + return { ...res.data.result }; + }; + + const addObservableToIncident = async ( + observable: Observable, + incidentID: string + ): Promise => { + try { + return await _addObservable( + observable, + getAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observable to security incident with id ${incidentID}` + ); + } + }; + + const bulkAddObservableToIncident = async ( + observables: Observable[], + incidentID: string + ): Promise => { + try { + return await _addObservable( + observables, + getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID) + ); + } catch (error) { + throw createServiceError( + error, + `Unable to add observables to security incident with id ${incidentID}` + ); + } + }; + return { + ...snService, + addObservableToIncident, + bulkAddObservableToIncident, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index ed62df71430fd..c5b5fb64b8019 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -7,6 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { ExecutorParamsSchemaITSM, @@ -97,6 +98,10 @@ export interface ExternalService { createIncident: (params: ExternalServiceParamsCreate) => Promise; updateIncident: (params: ExternalServiceParamsUpdate) => Promise; findIncidents: (params?: Record) => Promise; + getUrl: () => string; + checkInstance: (res: AxiosResponse) => void; + getApplicationInformation: () => Promise; + checkIfApplicationIsInstalled: () => Promise; } export type PushToServiceApiParams = ExecutorSubActionPushParams; @@ -174,7 +179,7 @@ export interface ServiceNowIncident { [x: string]: unknown; } -export interface ExternalServiceApi { +export interface ExternalServiceAPI { getChoices: (args: GetChoicesHandlerArgs) => Promise; getFields: (args: GetCommonFieldsHandlerArgs) => Promise; handshake: (args: HandshakeApiHandlerArgs) => Promise; @@ -235,6 +240,41 @@ export interface SNProductsConfigValue { appScope: string; useImportAPI: boolean; importSetTable: string; + commentFieldKey: string; } export type SNProductsConfig = Record; + +export enum ObservableTypes { + ip4 = 'ipv4-addr', + url = 'URL', + sha256 = 'SHA256', +} + +export interface Observable { + value: string; + type: ObservableTypes; +} + +export interface ObservableResponse { + value: string; + observable_sys_id: ObservableTypes; +} + +export interface ExternalServiceSIR extends ExternalService { + addObservableToIncident: ( + observable: Observable, + incidentID: string + ) => Promise; + bulkAddObservableToIncident: ( + observables: Observable[], + incidentID: string + ) => Promise; +} + +export type ServiceFactory = ( + credentials: ExternalServiceCredentials, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities, + serviceConfig: SNProductsConfigValue +) => ExternalServiceSIR | ExternalService; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts new file mode 100644 index 0000000000000..4fae985e60af3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Incident, PartialIncident, ResponseError } from './types'; +import { FIELD_PREFIX } from './config'; +import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; +import * as i18n from './translations'; + +export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => + useOldApi + ? incident + : Object.entries(incident).reduce( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} as Incident + ); + +const createErrorMessage = (errorResponse: ResponseError): string => { + if (errorResponse == null) { + return 'unknown'; + } + + const { error } = errorResponse; + return error != null ? `${error?.message}: ${error?.detail}` : 'unknown'; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const createServiceError = (error: any, message: string) => + new Error( + getErrorMessage( + i18n.SERVICENOW, + `${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}` + ) + ); + +export const getPushedDate = (timestamp?: string) => { + if (timestamp != null) { + return new Date(addTimeZoneToDate(timestamp)).toISOString(); + } + + return new Date().toISOString(); +}; From 4d22f7a71e36d043ca8fbf9cc35164c8d7da738b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 21 Sep 2021 14:43:01 +0300 Subject: [PATCH 39/92] Unique observables --- .../servicenow/api_sir.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index b4a6d3f127b84..5a123c1641f52 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { isString } from 'lodash'; + import { ExecutorSubActionPushParamsSIR, ExternalServiceAPI, @@ -16,14 +18,29 @@ import { import { api } from './api'; +const SPLIT_REGEX = /[ ,|\r\n\t]+/; + const formatObservables = (observables: string | string[], type: ObservableTypes) => { /** * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. * Before the application the observables were being sent to ServiceNow as a concatenated string with * delimiter. With the application the format changed to an array of observables. */ - const obsAsArray = Array.isArray(observables) ? observables : observables.split(/[ ,|\r\n\t]+/); - return obsAsArray.map((obs) => ({ value: obs, type })); + const obsAsArray = Array.isArray(observables) ? observables : observables.split(SPLIT_REGEX); + const uniqueObservables = new Set(obsAsArray); + return [...uniqueObservables].map((obs) => ({ value: obs, type })); +}; + +const combineObservables = (a: string | string[], b: string | string[]): string | string[] => { + if (isString(a) && Array.isArray(b)) { + return [...b, ...a.split(SPLIT_REGEX)]; + } + + if (isString(b) && Array.isArray(a)) { + return [...a, ...b.split(SPLIT_REGEX)]; + } + + return Array.isArray(a) && Array.isArray(b) ? [...a, ...b] : `${a},${b}`; }; const pushToServiceHandler = async ({ @@ -52,8 +69,7 @@ const pushToServiceHandler = async ({ const sirExternalService = externalService as ExternalServiceSIR; const obsWithType: Array<[string | string[], ObservableTypes]> = [ - [destIP ?? [], ObservableTypes.ip4], - [sourceIP ?? [], ObservableTypes.ip4], + [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], [malwareHash ?? [], ObservableTypes.sha256], [malwareUrl ?? [], ObservableTypes.url], ]; From 1ba41d7568fb2ec651b10dd8ebd3c9f535389321 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 21 Sep 2021 14:45:52 +0300 Subject: [PATCH 40/92] Push only if there are observables --- .../server/builtin_action_types/servicenow/api_sir.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index 5a123c1641f52..ba0b79312894c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -75,7 +75,10 @@ const pushToServiceHandler = async ({ ]; const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); - await sirExternalService.bulkAddObservableToIncident(observables, res.id); + if (observables.length > 0) { + await sirExternalService.bulkAddObservableToIncident(observables, res.id); + } + return res; }; From 783e44e40796542ae8f2f4fe97bcb38d38e6ccee Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 16:33:29 +0300 Subject: [PATCH 41/92] Change labels to plural --- .../components/connectors/servicenow/translations.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts index 5e4fdc4bfa2f9..637e8f3302515 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -30,11 +30,11 @@ export const CHOICES_API_ERROR = i18n.translate( ); export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', { - defaultMessage: 'Malware URL', + defaultMessage: 'Malware URLs', }); export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', { - defaultMessage: 'Malware Hash', + defaultMessage: 'Malware Hashes', }); export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', { @@ -46,11 +46,11 @@ export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.sub }); export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', { - defaultMessage: 'Source IP', + defaultMessage: 'Source IPs', }); export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', { - defaultMessage: 'Destination IP', + defaultMessage: 'Destination IPs', }); export const PRIORITY = i18n.translate( From b9f95e0dcb7f0f875c7ac714d2a7200efd71b092 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 17:53:56 +0300 Subject: [PATCH 42/92] Pass correlation ID and value --- .../cases/server/connectors/servicenow/itsm_format.ts | 10 +++++++++- .../cases/server/connectors/servicenow/sir_format.ts | 2 ++ .../cases/server/connectors/servicenow/types.ts | 11 +++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts index bc9d50026d1f8..d87f3fed58e03 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts @@ -16,5 +16,13 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => { category = null, subcategory = null, } = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; - return { severity, urgency, impact, category, subcategory }; + return { + severity, + urgency, + impact, + category, + subcategory, + correlation_id: theCase.id, + correlation_display: 'Elastic Case', + }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts index b48a1b7f734c8..64de8a6228649 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts @@ -68,5 +68,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { category, subcategory, priority, + correlation_id: theCase.id, + correlation_display: 'Elastic Case', }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/types.ts b/x-pack/plugins/cases/server/connectors/servicenow/types.ts index 2caebc3dab316..d13849f59577c 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/types.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/types.ts @@ -8,7 +8,12 @@ import { ServiceNowITSMFieldsType } from '../../../common'; import { ICasesConnector } from '../types'; -export interface ServiceNowSIRFieldsType { +interface CorrelationValues { + correlation_id: string | null; + correlation_display: string | null; +} + +export interface ServiceNowSIRFieldsType extends CorrelationValues { dest_ip: string | null; source_ip: string | null; category: string | null; @@ -26,7 +31,9 @@ export type AlertFieldMappingAndValues = Record< // ServiceNow ITSM export type ServiceNowITSMCasesConnector = ICasesConnector; -export type ServiceNowITSMFormat = ICasesConnector['format']; +export type ServiceNowITSMFormat = ICasesConnector< + ServiceNowITSMFieldsType & CorrelationValues +>['format']; export type ServiceNowITSMGetMapping = ICasesConnector['getMapping']; // ServiceNow SIR From 6df9ed35d4dfc558ec1841a826318155f05b29c4 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 18:32:45 +0300 Subject: [PATCH 43/92] Show errors on the callout --- .../builtin_action_types/servicenow/api.ts | 3 +- .../application_required_callout.tsx | 18 +- .../servicenow/servicenow_connectors.tsx | 212 +++++++++--------- .../servicenow/translations.ts | 18 +- .../servicenow/use_get_app_info.tsx | 7 +- 5 files changed, 139 insertions(+), 119 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index 75b9b8cb306d0..32a2d0296d4c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -9,6 +9,7 @@ import { HttpSetup } from 'kibana/public'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config'; import { BASE_ACTION_API_PATH } from '../../../constants'; +import { API_INFO_ERROR } from './translations'; import { AppInfo, RESTApiError } from './types'; export async function getChoices({ @@ -63,7 +64,7 @@ export async function getAppInfo({ }); if (!response.ok) { - throw new Error(`Received status: ${response.status} when attempting to get app info`); + throw new Error(API_INFO_ERROR(response.status)); } const data = await response.json(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 2d26f8b021efa..8dd7e0c8c38a9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -17,7 +17,18 @@ const content = i18n.translate( } ); -const ApplicationRequiredCalloutComponent: React.FC = () => { +const ERROR_MESSAGE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.errorMessage', + { + defaultMessage: 'Error message', + } +); + +interface Props { + message: string | null; +} + +const ApplicationRequiredCalloutComponent: React.FC = ({ message }) => { return ( <> @@ -34,6 +45,11 @@ const ApplicationRequiredCalloutComponent: React.FC = () => { )} >

{content}

+ {message && ( +

+ {ERROR_MESSAGE}: {message} +

+ )}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 93787e513a3f8..2af66b54b9885 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -23,127 +23,129 @@ import { updateActionConnector } from '../../../lib/action_connector_api'; import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../../../actions/server/constants/connectors'; import { Credentials } from './credentials'; -const ServiceNowConnectorFields: React.FC< - ActionConnectorFieldsProps -> = ({ - action, - editActionSecrets, - editActionConfig, - errors, - consumer, - readOnly, - setCallbacks, - isEdit, -}) => { - const { - http, - notifications: { toasts }, - } = useKibana().services; - const { apiUrl, isLegacy } = action.config; - const { username, password } = action.secrets; - - const [showModal, setShowModal] = useState(false); - - const { fetchAppInfo, isLoading } = useGetAppInfo({ - toastNotifications: toasts, - actionTypeId: action.actionTypeId, - }); - - const [applicationRequired, setApplicationRequired] = useState(false); - - const getApplicationInfo = useCallback(async () => { - try { - const res = await fetchAppInfo(action); - if (isRESTApiError(res)) { - setApplicationRequired(true); - return; - } +const ServiceNowConnectorFields: React.FC> = + ({ + action, + editActionSecrets, + editActionConfig, + errors, + consumer, + readOnly, + setCallbacks, + isEdit, + }) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; + const { apiUrl, isLegacy } = action.config; + const { username, password } = action.secrets; - return res; - } catch (e) { - // We need to throw here so the connector will be not be saved. - throw e; - } - }, [action, fetchAppInfo]); + const [showModal, setShowModal] = useState(false); - const beforeActionConnectorSave = useCallback(async () => { - if (ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy) { - await getApplicationInfo(); - } - }, [getApplicationInfo, isLegacy]); + const { fetchAppInfo, isLoading } = useGetAppInfo({ + toastNotifications: toasts, + actionTypeId: action.actionTypeId, + }); - const afterActionConnectorSave = useCallback(async () => { - // TODO: Implement - }, []); + const [applicationRequired, setApplicationRequired] = useState(false); + const [applicationInfoErrorMsg, setApplicationInfoErrorMsg] = useState(null); - useEffect(() => setCallbacks({ beforeActionConnectorSave, afterActionConnectorSave }), [ - afterActionConnectorSave, - beforeActionConnectorSave, - setCallbacks, - ]); + const getApplicationInfo = useCallback(async () => { + try { + const res = await fetchAppInfo(action); + if (isRESTApiError(res)) { + throw new Error(res.error?.message ?? i18n.UNKNOWN); + } + + return res; + } catch (e) { + setApplicationRequired(true); + setApplicationInfoErrorMsg(e.message); + // We need to throw here so the connector will be not be saved. + throw e; + } + }, [action, fetchAppInfo]); - const onMigrateClick = useCallback(() => setShowModal(true), []); - const onModalCancel = useCallback(() => setShowModal(false), []); + const beforeActionConnectorSave = useCallback(async () => { + if (ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy) { + await getApplicationInfo(); + } + }, [getApplicationInfo, isLegacy]); - const onModalConfirm = useCallback(async () => { - await getApplicationInfo(); - await updateActionConnector({ - http, - connector: { - name: action.name, - config: { apiUrl, isLegacy: false }, - secrets: { username, password }, - }, - id: action.id, - }); + const afterActionConnectorSave = useCallback(async () => { + // TODO: Implement + }, []); - editActionConfig('isLegacy', false); - setShowModal(false); + useEffect( + () => setCallbacks({ beforeActionConnectorSave, afterActionConnectorSave }), + [afterActionConnectorSave, beforeActionConnectorSave, setCallbacks] + ); - toasts.addSuccess({ - title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), - text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, - }); - }, [ - getApplicationInfo, - http, - action.name, - action.id, - apiUrl, - username, - password, - editActionConfig, - toasts, - ]); + const onMigrateClick = useCallback(() => setShowModal(true), []); + const onModalCancel = useCallback(() => setShowModal(false), []); - return ( - <> - {showModal && ( - { + await getApplicationInfo(); + await updateActionConnector({ + http, + connector: { + name: action.name, + config: { apiUrl, isLegacy: false }, + secrets: { username, password }, + }, + id: action.id, + }); + + editActionConfig('isLegacy', false); + setShowModal(false); + + toasts.addSuccess({ + title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), + text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, + }); + }, [ + getApplicationInfo, + http, + action.name, + action.id, + apiUrl, + username, + password, + editActionConfig, + toasts, + ]); + + return ( + <> + {showModal && ( + + )} + {ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy && } + {ENABLE_NEW_SN_ITSM_CONNECTOR && isLegacy && ( + + )} + - )} - {ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy && } - {ENABLE_NEW_SN_ITSM_CONNECTOR && isLegacy && } - - {applicationRequired && } - - ); -}; + {applicationRequired && } + + ); + }; // eslint-disable-next-line import/no-default-export export { ServiceNowConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index b438d674403a7..2157aba8e973c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -204,12 +204,11 @@ export const PRIORITY_LABEL = i18n.translate( } ); -export const APP_INFO_API_ERROR = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetAppInfoMessage', - { - defaultMessage: 'Unable to get application information.', - } -); +export const API_INFO_ERROR = (status: number) => + i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiInfoError', { + values: { status }, + defaultMessage: 'Received status: {status} when attempting to get application information', + }); export const INSTALL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.install', @@ -264,3 +263,10 @@ export const SN_INSTANCE_LABEL = i18n.translate( defaultMessage: 'ServiceNow instance', } ); + +export const UNKNOWN = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.snInstanceLabel', + { + defaultMessage: 'UNKNOWN', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx index 2623b931d117e..1f75d81f6478d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -9,7 +9,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { ToastsApi } from 'kibana/public'; import { getAppInfo } from './api'; import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; -import * as i18n from './translations'; export interface UseGetChoicesProps { actionTypeId: string; @@ -57,14 +56,10 @@ export const useGetAppInfo = ({ if (!didCancel.current) { setIsLoading(false); } - toastNotifications.addDanger({ - title: i18n.APP_INFO_API_ERROR, - text: error.message, - }); throw error; } }, - [actionTypeId, toastNotifications] + [actionTypeId] ); useEffect(() => { From 9c8ebf2c1ea96ea57b79cd23ce0c60edecf11eb9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 18:55:03 +0300 Subject: [PATCH 44/92] Improve alerts tooltip --- .../components/actions_connectors_list.tsx | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 2251725addb30..7674254e2677d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -46,8 +46,11 @@ import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import ConnectorEditFlyout from '../../action_connector_form/connector_edit_flyout'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../../../actions/server/constants/connectors'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/constants/connectors'; const ActionsConnectorsList: React.FunctionComponent = () => { const { @@ -170,6 +173,14 @@ const ActionsConnectorsList: React.FunctionComponent = () => { const checkEnabledResult = checkActionTypeEnabled( actionTypesIndex && actionTypesIndex[item.actionTypeId] ); + const itemConfig = ( + item as UserConfiguredActionConnector, Record> + ).config; + const showLegacyTooltip = + itemConfig.isLegacy && + // TODO: Remove when applications are certified + ((ENABLE_NEW_SN_ITSM_CONNECTOR && item.actionTypeId === '.servicenow') || + (ENABLE_NEW_SN_SIR_CONNECTOR && item.actionTypeId === '.servicenow-sir')); const link = ( <> @@ -193,19 +204,22 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} - {ENABLE_NEW_SN_ITSM_CONNECTOR && - (item as UserConfiguredActionConnector< - Record, - Record - >).config.isLegacy && ( - - )} + {showLegacyTooltip && ( + + )} ); From 47fc26a4ada0006fee37bb58f2b420613ab3cec3 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 18:58:31 +0300 Subject: [PATCH 45/92] Improve cases tooltip --- .../configure_cases/connectors_dropdown.tsx | 3 ++- .../components/configure_cases/translations.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index 21b9882b2ef4c..cbc44955466a5 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -98,7 +98,8 @@ const ConnectorsDropdownComponent: React.FC = ({ size={ICON_SIZE} type="alert" color="warning" - content="Deprecated connector. Please create a new one." + title={i18n.DEPRECATED_TOOLTIP_TITLE} + content={i18n.DEPRECATED_TOOLTIP_CONTENT} /> )} diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 878d261369340..cd4a030bd622a 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -162,3 +162,17 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => values: { connectorName }, defaultMessage: 'Update { connectorName }', }); + +export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipTitle', + { + defaultMessage: 'Deprecated connector', + } +); + +export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipTitle', + { + defaultMessage: 'Please upgrade your connector', + } +); From 3c0b9dbad110a4aafe695d0b19ac59f7a5f9bb7f Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 19:13:36 +0300 Subject: [PATCH 46/92] Warning callout on cases configuration page --- .../components/configure_cases/connectors.tsx | 12 +++++-- .../configure_cases/deprecated_callout.tsx | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/configure_cases/deprecated_callout.tsx diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index 40f314a653882..e9357147fbd72 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -22,6 +22,7 @@ import * as i18n from './translations'; import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; import { Mapping } from './mapping'; import { ActionTypeConnector, ConnectorTypes } from '../../../common'; +import { DeprecatedCallout } from './deprecated_callout'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -53,11 +54,13 @@ const ConnectorsComponent: React.FC = ({ selectedConnector, updateConnectorDisabled, }) => { - const connectorsName = useMemo( - () => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none', + const connector = useMemo( + () => connectors.find((c) => c.id === selectedConnector.id), [connectors, selectedConnector.id] ); + const connectorsName = connector?.name ?? 'none'; + const actionTypeName = useMemo( () => actionTypes.find((c) => c.id === selectedConnector.type)?.name ?? 'Unknown', [actionTypes, selectedConnector.type] @@ -107,6 +110,11 @@ const ConnectorsComponent: React.FC = ({ appendAddConnectorButton={true} /> + {connector?.config.isLegacy && ( + + + + )} {selectedConnector.type !== ConnectorTypes.none ? ( { + return ( + <> + +

+ {i18n.translate('xpack.cases.configureCases..deprecatedCalloutMigrate', { + defaultMessage: 'Please update your connector', + })} +

+
+ + ); +}; + +export const DeprecatedCallout = memo(DeprecatedCalloutComponent); From c306206841efcd42d59c08264ee7e5a98eb30b14 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 19:14:22 +0300 Subject: [PATCH 47/92] Fix tooltip content --- .../cases/public/components/configure_cases/translations.ts | 2 +- .../components/actions_connectors_list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index cd4a030bd622a..75472c9667371 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -173,6 +173,6 @@ export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( 'xpack.cases.configureCases.deprecatedTooltipTitle', { - defaultMessage: 'Please upgrade your connector', + defaultMessage: 'Please update your connector', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 7674254e2677d..163e963b521f1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -216,7 +216,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => { )} content={i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.missingSecretsDescription', - { defaultMessage: 'Please upgrade your connector' } + { defaultMessage: 'Please update your connector' } )} /> )} From 3a17b5f0143618716bb65880b4f31cf67702e85d Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 27 Sep 2021 19:52:17 +0300 Subject: [PATCH 48/92] Add help text --- .../servicenow_itsm_case_fields.tsx | 36 +++++++++++-------- .../servicenow/servicenow_sir_case_fields.tsx | 12 ++++--- .../servicenow/servicenow_sir_params.tsx | 8 ++--- .../servicenow/translations.ts | 28 +++++++++++++++ 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index ab7baa30590ab..c11cd18ef27a2 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -153,22 +153,30 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< }, [category, impact, onChange, severity, subcategory, urgency]); return isEdit ? ( -
+
+ {showMappingWarning && ( + + + + + + )} - {showMappingWarning && } + + + onChangeCb('urgency', e.target.value)} + /> + + - - onChangeCb('urgency', e.target.value)} - /> - diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 6c3018a554d7b..aa5334ea41302 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -169,10 +169,14 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); return isEdit ? ( -
- - {showMappingWarning && } - +
+ {showMappingWarning && ( + + + + + + )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 0ba52014fa1f9..81eba1d478a35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -167,7 +167,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< /> - + - + - + - + Date: Mon, 27 Sep 2021 19:57:46 +0300 Subject: [PATCH 49/92] Change from string to array --- .../connectors/servicenow/sir_format.ts | 21 +++++++++---------- .../server/connectors/servicenow/types.ts | 8 +++---- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts index 64de8a6228649..bc51ea8fad581 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts @@ -32,11 +32,11 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { malware_url: new Set(), }; - let sirFields: Record = { - dest_ip: null, - source_ip: null, - malware_hash: null, - malware_url: null, + let sirFields: Record = { + dest_ip: [], + source_ip: [], + malware_hash: [], + malware_url: [], }; const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter( @@ -44,18 +44,17 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { ); if (fieldsToAdd.length > 0) { - sirFields = alerts.reduce>((acc, alert) => { + sirFields = alerts.reduce>((acc, alert) => { fieldsToAdd.forEach((alertField) => { const field = get(alertFieldMapping[alertField].alertPath, alert); if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); acc = { ...acc, - [alertFieldMapping[alertField].sirFieldKey]: `${ - acc[alertFieldMapping[alertField].sirFieldKey] != null - ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` - : field - }`, + [alertFieldMapping[alertField].sirFieldKey]: [ + ...acc[alertFieldMapping[alertField].sirFieldKey], + field, + ], }; } }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/types.ts b/x-pack/plugins/cases/server/connectors/servicenow/types.ts index d13849f59577c..b0e71cbe5e743 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/types.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/types.ts @@ -14,12 +14,12 @@ interface CorrelationValues { } export interface ServiceNowSIRFieldsType extends CorrelationValues { - dest_ip: string | null; - source_ip: string | null; + dest_ip: string[] | null; + source_ip: string[] | null; category: string | null; subcategory: string | null; - malware_hash: string | null; - malware_url: string | null; + malware_hash: string[] | null; + malware_url: string[] | null; priority: string | null; } From caa4292c37c6df69fa11430451990991d047514b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 00:40:49 +0300 Subject: [PATCH 50/92] Fix i18n --- .../cases/public/components/configure_cases/translations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 75472c9667371..4a775c78d4ab8 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -171,7 +171,7 @@ export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( ); export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( - 'xpack.cases.configureCases.deprecatedTooltipTitle', + 'xpack.cases.configureCases.deprecatedTooltipContent', { defaultMessage: 'Please update your connector', } From a855d0fdf7d9aaa37a815ebb60e95ddf7d63147f Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 10:19:05 +0300 Subject: [PATCH 51/92] Fix spelling --- .../plugins/actions/server/builtin_action_types/pagerduty.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index 5d83b658111e4..7710ff79d08b4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -143,7 +143,7 @@ export function getActionType({ }), validate: { config: schema.object(configSchemaProps, { - validate: curry(valdiateActionTypeConfig)(configurationUtilities), + validate: curry(validateActionTypeConfig)(configurationUtilities), }), secrets: SecretsSchema, params: ParamsSchema, @@ -152,7 +152,7 @@ export function getActionType({ }; } -function valdiateActionTypeConfig( +function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType ) { From 3af5428780edf3ef19fdbe51f44bc5130ee9009e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 11:07:24 +0300 Subject: [PATCH 52/92] Update incidents for ITSM --- .../servicenow/servicenow_itsm_params.tsx | 75 +++++++++++++------ .../servicenow/translations.ts | 21 ++++++ 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index b243afb375e6d..3dc738e56fbd7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiSwitch, } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; @@ -24,6 +25,9 @@ import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; +const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}'; +const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}'; + const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { category: [], @@ -53,8 +57,13 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); + const hasUpdateIncident = + incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; + const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); + const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -90,6 +99,14 @@ const ServiceNowParamsFields: React.FunctionComponent< ); }, []); + const onUpdateIncidentSwitchChange = useCallback(() => { + const newCorrelationID = !updateIncident + ? UPDATE_INCIDENT_VARIABLE + : NOT_UPDATE_INCIDENT_VARIABLE; + editSubActionProperty('correlation_id', newCorrelationID); + setUpdateIncident(!updateIncident); + }, [editSubActionProperty, updateIncident]); + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); @@ -119,7 +136,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -136,7 +153,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -236,25 +253,41 @@ const ServiceNowParamsFields: React.FunctionComponent< - 0 && - incident.short_description !== undefined - } - label={i18n.SHORT_DESCRIPTION_LABEL} - > - - + + + 0 && + incident.short_description !== undefined + } + label={i18n.SHORT_DESCRIPTION_LABEL} + > + + + + + + + + + + Date: Tue, 28 Sep 2021 11:42:31 +0300 Subject: [PATCH 53/92] Update incidents for SIR --- .../builtin_action_types/servicenow/config.ts | 9 ++++++ .../servicenow/servicenow_itsm_params.tsx | 4 +-- .../servicenow/servicenow_sir_params.tsx | 29 +++++++++++++++++-- 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..9d5fafbf5a0ea --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}'; +export const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 3dc738e56fbd7..24a52da83b42b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -24,9 +24,7 @@ import { useGetChoices } from './use_get_choices'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; - -const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}'; -const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}'; +import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 81eba1d478a35..4a1631c0b4f53 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiSwitch, } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; @@ -23,6 +24,7 @@ import * as i18n from './translations'; import { useGetChoices } from './use_get_choices'; import { ServiceNowSIRActionParams, Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; +import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -50,8 +52,13 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); + const hasUpdateIncident = + incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; + const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); + const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; + const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -87,6 +94,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< ); }, []); + const onUpdateIncidentSwitchChange = useCallback(() => { + const newCorrelationID = !updateIncident + ? UPDATE_INCIDENT_VARIABLE + : NOT_UPDATE_INCIDENT_VARIABLE; + editSubActionProperty('correlation_id', newCorrelationID); + setUpdateIncident(!updateIncident); + }, [editSubActionProperty, updateIncident]); + const { isLoading: isLoadingChoices } = useGetChoices({ http, toastNotifications: toasts, @@ -115,7 +130,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -132,7 +147,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: {}, + incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, comments: [], }, index @@ -277,6 +292,16 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined} label={i18n.COMMENTS_LABEL} /> + + + + ); }; From 6a3db84f407ef34c6901016d3017ade44842f7ba Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 12:05:43 +0300 Subject: [PATCH 54/92] Fix types --- .../builtin_action_types/servicenow/mocks.ts | 12 ++++++++ .../servicenow/service.test.ts | 30 +++++++------------ .../email/email_connector.test.tsx | 8 +++++ .../servicenow_itsm_params.test.tsx | 2 ++ .../servicenow/servicenow_sir_params.test.tsx | 2 ++ .../servicenow/servicenow_sir_params.tsx | 16 +++++++--- .../uptime/public/state/api/alert_actions.ts | 2 ++ 7 files changed, 48 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 909200472be33..fa93088b1a73e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -95,6 +95,16 @@ const createMock = (): jest.Mocked => { }) ), findIncidents: jest.fn(), + getApplicationInformation: jest.fn().mockImplementation(() => + Promise.resolve({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }) + ), + checkIfApplicationIsInstalled: jest.fn(), + getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'), + checkInstance: jest.fn(), }; return service; @@ -114,6 +124,8 @@ const executorParams: ExecutorSubActionPushParams = { impact: '3', category: 'software', subcategory: 'os', + correlation_id: 'alertID', + correlation_display: 'Alerting', }, comments: [ { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 4ae481f3badaa..28c4e1d7b1350 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -14,6 +14,7 @@ import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { serviceNowCommonFields, serviceNowChoices } from './mocks'; +import { snExternalServiceConfig } from './config'; const logger = loggingSystemMock.create().get() as jest.Mocked; jest.mock('axios'); @@ -30,15 +31,12 @@ axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); -const table = 'incident'; -const actionTypeId = '.servicenow'; describe('ServiceNow service', () => { let service: ExternalService; beforeEach(() => { service = createExternalService( - table, { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. @@ -47,7 +45,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - actionTypeId + snExternalServiceConfig.servicenow ); }); @@ -59,14 +57,13 @@ describe('ServiceNow service', () => { test('throws without url', () => { expect(() => createExternalService( - table, { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + snExternalServiceConfig.servicenow ) ).toThrow(); }); @@ -74,14 +71,13 @@ describe('ServiceNow service', () => { test('throws without username', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + snExternalServiceConfig.servicenow ) ).toThrow(); }); @@ -89,14 +85,13 @@ describe('ServiceNow service', () => { test('throws without password', () => { expect(() => createExternalService( - table, { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, }, logger, configurationUtilities, - actionTypeId + snExternalServiceConfig.servicenow ) ).toThrow(); }); @@ -127,14 +122,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -210,14 +204,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -308,14 +301,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } ); patchMock.mockImplementation(() => ({ @@ -410,14 +402,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -479,14 +470,13 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( - 'sn_si_incident', { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, configurationUtilities, - actionTypeId + { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index f1c740b9a3acd..a96e1fc3dcb5d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -103,6 +103,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe( @@ -136,6 +138,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy(); @@ -169,6 +173,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(true); @@ -203,6 +209,8 @@ describe('EmailActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} readOnly={false} + setCallbacks={() => {}} + isEdit={false} /> ); expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(false); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index e864a8d3fd114..e1ae6b0b4ba46 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -31,6 +31,8 @@ const actionParams = { category: 'software', subcategory: 'os', externalId: null, + correlation_id: 'alertID', + correlation_display: 'Alerting', }, comments: [], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx index 8637af4cabc94..0b03a668bdd5d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx @@ -33,6 +33,8 @@ const actionParams = { priority: '1', subcategory: '20', externalId: null, + correlation_id: 'alertID', + correlation_display: 'Alerting', }, comments: [], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 4a1631c0b4f53..74739045c2d97 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -33,6 +33,14 @@ const defaultFields: Fields = { priority: [], }; +const valuesToString = (value: string | string[] | null): string | null => { + if (Array.isArray(value)) { + return value.join(','); + } + + return value; +}; + const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { @@ -188,7 +196,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'source_ip'} - inputTargetValue={incident?.source_ip ?? undefined} + inputTargetValue={valuesToString(incident?.source_ip) ?? undefined} /> @@ -198,7 +206,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'dest_ip'} - inputTargetValue={incident?.dest_ip ?? undefined} + inputTargetValue={valuesToString(incident?.dest_ip) ?? undefined} /> @@ -208,7 +216,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'malware_url'} - inputTargetValue={incident?.malware_url ?? undefined} + inputTargetValue={valuesToString(incident?.malware_url) ?? undefined} /> @@ -218,7 +226,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'malware_hash'} - inputTargetValue={incident?.malware_hash ?? undefined} + inputTargetValue={valuesToString(incident?.malware_hash) ?? undefined} /> diff --git a/x-pack/plugins/uptime/public/state/api/alert_actions.ts b/x-pack/plugins/uptime/public/state/api/alert_actions.ts index b0f5f3ea490e5..40a7af18ac906 100644 --- a/x-pack/plugins/uptime/public/state/api/alert_actions.ts +++ b/x-pack/plugins/uptime/public/state/api/alert_actions.ts @@ -189,6 +189,8 @@ function getServiceNowActionParams(): ServiceNowActionParams { category: null, subcategory: null, externalId: null, + correlation_id: null, + correlation_display: null, }, comments: [], }, From 59fee89f9de5305bf2bc6ea4343efba440bd2c7d Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 13:20:38 +0300 Subject: [PATCH 55/92] Fix backend tests --- .../servicenow/api.test.ts | 21 ++ .../servicenow/service.test.ts | 229 ++++++++++-------- .../servicenow/service.ts | 2 + .../connectors/servicenow/itsm_format.test.ts | 9 +- .../connectors/servicenow/itsm_format.ts | 2 +- .../connectors/servicenow/sir_format.test.ts | 55 +++-- .../connectors/servicenow/sir_format.ts | 2 +- 7 files changed, 190 insertions(+), 130 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 8d24e48d4d515..bd67399138bb6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -93,6 +93,9 @@ describe('api', () => { caller_id: 'elastic', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', + opened_by: 'elastic', }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -118,6 +121,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-1', }); @@ -132,6 +137,8 @@ describe('api', () => { comments: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-1', }); @@ -157,6 +164,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-1', }); @@ -171,6 +180,8 @@ describe('api', () => { work_notes: 'Another comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-1', }); @@ -243,6 +254,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -267,6 +280,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-3', }); @@ -281,6 +296,8 @@ describe('api', () => { comments: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-2', }); @@ -305,6 +322,8 @@ describe('api', () => { subcategory: 'os', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-3', }); @@ -319,6 +338,8 @@ describe('api', () => { work_notes: 'A comment', description: 'Incident description', short_description: 'Incident title', + correlation_display: 'Alerting', + correlation_id: 'alertID', }, incidentId: 'incident-2', }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 28c4e1d7b1350..6fab34eedc0c2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -29,9 +29,98 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; -const patchMock = utils.patch as jest.Mock; const configurationUtilities = actionsConfigMock.create(); +const getImportSetAPIResponse = (update = false) => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: 'https://dev102283.service-now.com/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', + sys_id: '1', + }, + ], +}); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, + }, + })); + +const mockImportIncident = (update: boolean) => + requestMock.mockImplementationOnce(() => ({ + data: getImportSetAPIResponse(update), + })); + +const createIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(false); + // Get incident response + requestMock.mockImplementationOnce(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, + })); + + return await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const updateIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(true); + // Get incident response + requestMock.mockImplementationOnce(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + })); + + return await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const expectImportedIncident = (update: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/x_elas2_inc_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { + u_short_description: 'title', + u_description: 'desc', + ...(update ? { elastic_incident_id: '1' } : {}), + }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + method: 'get', + }); +}; + describe('ServiceNow service', () => { let service: ExternalService; @@ -45,7 +134,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - snExternalServiceConfig.servicenow + snExternalServiceConfig['.servicenow'] ); }); @@ -63,7 +152,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - snExternalServiceConfig.servicenow + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -77,7 +166,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - snExternalServiceConfig.servicenow + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -91,7 +180,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - snExternalServiceConfig.servicenow + snExternalServiceConfig['.servicenow'] ) ).toThrow(); }); @@ -117,6 +206,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + method: 'get', }); }); @@ -128,7 +218,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -141,6 +231,7 @@ describe('ServiceNow service', () => { logger, configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + method: 'get', }); }); @@ -167,14 +258,7 @@ describe('ServiceNow service', () => { describe('createIncident', () => { test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); - - const res = await service.createIncident({ - incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, - }); - + const res = await createIncident(service); expect(res).toEqual({ title: 'INC01', id: '1', @@ -184,22 +268,8 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); - - await service.createIncident({ - incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, - }); - - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident', - method: 'post', - data: { short_description: 'title', description: 'desc' }, - }); + await createIncident(service); + expectImportedIncident(false); }); test('it should call request with correct arguments when table changes', async () => { @@ -210,24 +280,17 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } + snExternalServiceConfig['.servicenow-sir'] ); - 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' } as ServiceNowITSMIncident, - }); - - expect(requestMock).toHaveBeenCalledWith({ + const res = await createIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(2, { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident', + url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', - data: { short_description: 'title', description: 'desc' }, + data: { u_short_description: 'title', u_description: 'desc' }, }); expect(res.url).toEqual( @@ -245,7 +308,7 @@ describe('ServiceNow service', () => { incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }) ).rejects.toThrow( - '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' ); }); @@ -263,14 +326,7 @@ describe('ServiceNow service', () => { describe('updateIncident', () => { test('it updates the incident correctly', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); - - const res = await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, - }); + const res = await updateIncident(service); expect(res).toEqual({ title: 'INC01', @@ -281,22 +337,8 @@ describe('ServiceNow service', () => { }); test('it should call request with correct arguments', async () => { - patchMock.mockImplementation(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); - - await service.updateIncident({ - incidentId: '1', - incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, - }); - - expect(patchMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', - data: { short_description: 'title', description: 'desc' }, - }); + await updateIncident(service); + expectImportedIncident(true); }); test('it should call request with correct arguments when table changes', async () => { @@ -307,24 +349,17 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } + snExternalServiceConfig['.servicenow-sir'] ); - 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' } as ServiceNowITSMIncident, - }); - - expect(patchMock).toHaveBeenCalledWith({ + const res = await updateIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(2, { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', - data: { short_description: 'title', description: 'desc' }, + url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, }); expect(res.url).toEqual( @@ -333,7 +368,7 @@ describe('ServiceNow service', () => { }); test('it should throw an error', async () => { - patchMock.mockImplementation(() => { + requestMock.mockImplementation(() => { throw new Error('An error has occurred'); }); @@ -343,28 +378,10 @@ describe('ServiceNow service', () => { incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }) ).rejects.toThrow( - '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' ); }); - 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' } }, - })); - - const res = await service.updateIncident({ - incidentId: '1', - incident: { comment: 'comment-1' }, - }); - - expect(res).toEqual({ - title: 'INC011', - id: '11', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11', - }); - }); - test('it should throw an error when instance is not alive', async () => { requestMock.mockImplementation(() => ({ status: 200, @@ -388,7 +405,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://dev102283.service-now.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -408,7 +425,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -420,7 +437,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://dev102283.service-now.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -456,7 +473,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://dev102283.service-now.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -476,7 +493,7 @@ describe('ServiceNow service', () => { }, logger, configurationUtilities, - { ...snExternalServiceConfig.servicenow, table: 'sn_si_incident' } + { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' } ); requestMock.mockImplementation(() => ({ @@ -489,7 +506,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://dev102283.service-now.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index bf76123c7cf76..73b9fd45db5ce 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -114,6 +114,7 @@ export const createExternalService: ServiceFactory = ( url: getVersionUrl(), logger, configurationUtilities, + method: 'get', }); checkInstance(res); @@ -141,6 +142,7 @@ export const createExternalService: ServiceFactory = ( url: `${tableApiIncidentUrl}/${id}`, logger, configurationUtilities, + method: 'get', }); checkInstance(res); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts index 2cc1816e7fa67..ac9dc8839bfb8 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.test.ts @@ -10,6 +10,7 @@ import { format } from './itsm_format'; describe('ITSM formatter', () => { const theCase = { + id: 'case-id', connector: { fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' }, }, @@ -17,7 +18,11 @@ describe('ITSM formatter', () => { it('it formats correctly', async () => { const res = await format(theCase, []); - expect(res).toEqual(theCase.connector.fields); + expect(res).toEqual({ + ...theCase.connector.fields, + correlation_display: 'Elastic Case', + correlation_id: 'case-id', + }); }); it('it formats correctly when fields do not exist ', async () => { @@ -29,6 +34,8 @@ describe('ITSM formatter', () => { impact: null, category: null, subcategory: null, + correlation_display: 'Elastic Case', + correlation_id: null, }); }); }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts index d87f3fed58e03..1859ea1246f21 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/itsm_format.ts @@ -22,7 +22,7 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => { impact, category, subcategory, - correlation_id: theCase.id, + correlation_id: theCase.id ?? null, correlation_display: 'Elastic Case', }; }; diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts index fa103d4c1142d..b09272d0a5505 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.test.ts @@ -10,6 +10,7 @@ import { format } from './sir_format'; describe('ITSM formatter', () => { const theCase = { + id: 'case-id', connector: { fields: { destIp: true, @@ -26,13 +27,15 @@ describe('ITSM formatter', () => { it('it formats correctly without alerts', async () => { const res = await format(theCase, []); expect(res).toEqual({ - dest_ip: null, - source_ip: null, + dest_ip: [], + source_ip: [], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: null, - malware_url: null, + malware_hash: [], + malware_url: [], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -40,13 +43,15 @@ describe('ITSM formatter', () => { const invalidFields = { connector: { fields: null } } as CaseResponse; const res = await format(invalidFields, []); expect(res).toEqual({ - dest_ip: null, - source_ip: null, + dest_ip: [], + source_ip: [], category: null, subcategory: null, - malware_hash: null, - malware_url: null, + malware_hash: [], + malware_url: [], priority: null, + correlation_display: 'Elastic Case', + correlation_id: null, }); }); @@ -75,14 +80,18 @@ describe('ITSM formatter', () => { ]; const res = await format(theCase, alerts); expect(res).toEqual({ - dest_ip: '192.168.1.1,192.168.1.4', - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: ['192.168.1.1', '192.168.1.4'], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: - '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: [ + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', + ], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -111,13 +120,15 @@ describe('ITSM formatter', () => { ]; const res = await format(theCase, alerts); expect(res).toEqual({ - dest_ip: '192.168.1.1', - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: ['192.168.1.1'], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: ['9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); @@ -152,13 +163,15 @@ describe('ITSM formatter', () => { const res = await format(newCase, alerts); expect(res).toEqual({ - dest_ip: null, - source_ip: '192.168.1.2,192.168.1.3', + dest_ip: [], + source_ip: ['192.168.1.2', '192.168.1.3'], category: 'Denial of Service', subcategory: 'Inbound DDos', - malware_hash: null, - malware_url: 'https://attack.com,https://attack.com/api', + malware_hash: [], + malware_url: ['https://attack.com', 'https://attack.com/api'], priority: '2 - High', + correlation_display: 'Elastic Case', + correlation_id: 'case-id', }); }); }); diff --git a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts index bc51ea8fad581..9108408c4d089 100644 --- a/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts +++ b/x-pack/plugins/cases/server/connectors/servicenow/sir_format.ts @@ -67,7 +67,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => { category, subcategory, priority, - correlation_id: theCase.id, + correlation_id: theCase.id ?? null, correlation_display: 'Elastic Case', }; }; From 6d9520833c458b8ca9677948d668dbe961a2f2ee Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 13:35:45 +0300 Subject: [PATCH 56/92] Fix frontend tests --- .../connectors_dropdown.test.tsx | 24 ++++++++++++------- .../servicenow_sir_case_fields.test.tsx | 8 +++---- .../servicenow/servicenow.test.tsx | 3 +++ .../servicenow_itsm_params.test.tsx | 10 ++++++-- .../servicenow/servicenow_sir_params.test.tsx | 10 ++++++-- 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 8eae574776e2e..4e6ee742f442d 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -77,7 +77,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-servicenow-1", "inputDisplay": { type="logoSecurity" /> - + My Connector @@ -100,7 +102,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-resilient-2", "inputDisplay": { type="logoSecurity" /> - + My Connector 2 @@ -123,7 +127,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-jira-1", "inputDisplay": { type="logoSecurity" /> - + Jira @@ -146,7 +152,7 @@ describe('ConnectorsDropdown', () => { "data-test-subj": "dropdown-connector-servicenow-sir", "inputDisplay": { type="logoSecurity" /> - + My Connector SIR diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index 7d42c90a436f7..1117f75cddffc 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -68,16 +68,16 @@ describe('ServiceNowSIR Fields', () => { wrapper.update(); expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( - 'Destination IP: Yes' + 'Destination IPs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( - 'Source IP: Yes' + 'Source IPs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( - 'Malware URL: Yes' + 'Malware URLs: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual( - 'Malware Hash: Yes' + 'Malware Hashes: Yes' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual( 'Priority: 1 - Critical' diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index f1516f880dce4..b40db9c2dabda 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -43,6 +43,7 @@ describe('servicenow connector validation', () => { isPreconfigured: false, config: { apiUrl: 'https://dev94428.service-now.com/', + isLegacy: false, }, } as ServiceNowActionConnector; @@ -50,6 +51,7 @@ describe('servicenow connector validation', () => { config: { errors: { apiUrl: [], + isLegacy: [], }, }, secrets: { @@ -77,6 +79,7 @@ describe('servicenow connector validation', () => { config: { errors: { apiUrl: ['URL is required.'], + isLegacy: [], }, }, secrets: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index e1ae6b0b4ba46..30e09356e95dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -146,7 +146,10 @@ describe('ServiceNowITSMParamsFields renders', () => { }; mount(); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); @@ -168,7 +171,10 @@ describe('ServiceNowITSMParamsFields renders', () => { wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx index 0b03a668bdd5d..091b768e2d80d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.test.tsx @@ -176,7 +176,10 @@ describe('ServiceNowSIRParamsFields renders', () => { }; mount(); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); @@ -198,7 +201,10 @@ describe('ServiceNowSIRParamsFields renders', () => { wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ - incident: {}, + incident: { + correlation_display: 'Alerting', + correlation_id: '{{rule.id}}:{{alert.id}}', + }, comments: [], }); }); From 8711f17a804cc33861ca6db46bdb4929ecf8f3c3 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 16:55:33 +0300 Subject: [PATCH 57/92] Add service tests --- .../servicenow/service.test.ts | 535 ++++++++++++++---- .../servicenow/service.ts | 2 +- 2 files changed, 424 insertions(+), 113 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 6fab34eedc0c2..825b45f3a8e99 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { createExternalService } from './service'; import * as utils from '../lib/axios_utils'; @@ -47,6 +47,19 @@ const getImportSetAPIResponse = (update = false) => ({ ], }); +const getImportSetAPIError = () => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + status: 'error', + error_message: 'An error has occurred while importing the incident', + status_message: 'failure', + }, + ], +}); + const mockApplicationVersion = () => requestMock.mockImplementationOnce(() => ({ data: { @@ -59,15 +72,26 @@ const mockImportIncident = (update: boolean) => data: getImportSetAPIResponse(update), })); +const mockIncidentResponse = (update: boolean) => + requestMock.mockImplementation(() => ({ + data: { + result: { + sys_id: '1', + number: 'INC01', + ...(update + ? { sys_updated_on: '2020-03-10 12:24:20' } + : { sys_created_on: '2020-03-10 12:24:20' }), + }, + }, + })); + const createIncident = async (service: ExternalService) => { // Get application version mockApplicationVersion(); // Import set api response mockImportIncident(false); // Get incident response - requestMock.mockImplementationOnce(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - })); + mockIncidentResponse(false); return await service.createIncident({ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, @@ -80,9 +104,7 @@ const updateIncident = async (service: ExternalService) => { // Import set api response mockImportIncident(true); // Get incident response - requestMock.mockImplementationOnce(() => ({ - data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - })); + mockIncidentResponse(true); return await service.updateIncident({ incidentId: '1', @@ -257,140 +279,354 @@ describe('ServiceNow service', () => { }); describe('createIncident', () => { - test('it creates the incident correctly', async () => { - const res = await createIncident(service); - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + // new connectors + describe('import set table', () => { + test('it creates the incident correctly', async () => { + const res = await createIncident(service); + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - }); - test('it should call request with correct arguments', async () => { - await createIncident(service); - expectImportedIncident(false); - }); + test('it should call request with correct arguments', async () => { + await createIncident(service); + expect(requestMock).toHaveBeenCalledTimes(3); + expectImportedIncident(false); + }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities, - snExternalServiceConfig['.servicenow-sir'] - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - const res = await createIncident(service); - expect(requestMock).toHaveBeenNthCalledWith(2, { - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', - method: 'post', - data: { u_short_description: 'title', u_description: 'desc' }, + const res = await createIncident(service); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual( + 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.createIncident({ + test('it creates the incident correctly', async () => { + mockIncidentResponse(false); + const res = await service.createIncident({ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' - ); - }); + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); + + mockIncidentResponse(false); + + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://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' + ); + }); }); }); describe('updateIncident', () => { - test('it updates the incident correctly', async () => { - const res = await updateIncident(service); - - expect(res).toEqual({ - title: 'INC01', - id: '1', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + // new connectors + describe('import set table', () => { + test('it updates the incident correctly', async () => { + const res = await updateIncident(service); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + }); }); - }); - test('it should call request with correct arguments', async () => { - await updateIncident(service); - expectImportedIncident(true); - }); + test('it should call request with correct arguments', async () => { + await updateIncident(service); + expectImportedIncident(true); + }); - test('it should call request with correct arguments when table changes', async () => { - service = createExternalService( - { - config: { apiUrl: 'https://dev102283.service-now.com/' }, - secrets: { username: 'admin', password: 'admin' }, - }, - logger, - configurationUtilities, - snExternalServiceConfig['.servicenow-sir'] - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ); - const res = await updateIncident(service); - expect(requestMock).toHaveBeenNthCalledWith(2, { - axios, - logger, - configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', - method: 'post', - data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + const res = await updateIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual( + 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' + ); }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); }); - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); }); - await expect( - service.updateIncident({ + test('it updates the incident correctly', async () => { + mockIncidentResponse(true); + const res = await service.updateIncident({ incidentId: '1', incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, - }) - ).rejects.toThrow( - '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' - ); - }); + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + }); - test('it should throw an error when instance is not alive', async () => { - requestMock.mockImplementation(() => ({ - status: 200, - data: {}, - request: { connection: { servername: 'Developer instance' } }, - })); - await expect(service.getIncident('1')).rejects.toThrow( - 'There is an issue with your Service Now Instance. Please check Developer instance.' - ); + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false } + ); + + mockIncidentResponse(false); + + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + method: 'patch', + 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' + ); + }); }); }); @@ -530,4 +766,79 @@ describe('ServiceNow service', () => { ); }); }); + + describe('getUrl', () => { + test('it returns the instance url', async () => { + expect(service.getUrl()).toBe('https://dev102283.service-now.com'); + }); + }); + + describe('checkInstance', () => { + test('it throws an error if there is no result on data', () => { + const res = { status: 200, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow(); + }); + + test('it does NOT throws an error if the status > 400', () => { + const res = { status: 500, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).not.toThrow(); + }); + + test('it shows the servername', () => { + const res = { + status: 200, + data: {}, + request: { connection: { servername: 'https://example.com' } }, + } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow( + 'There is an issue with your Service Now Instance. Please check https://example.com.' + ); + }); + + describe('getApplicationInformation', () => { + test('it returns the application information', async () => { + mockApplicationVersion(); + const res = await service.getApplicationInformation(); + expect(res).toEqual({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getApplicationInformation()).rejects.toThrow( + '[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown' + ); + }); + }); + + describe('checkIfApplicationIsInstalled', () => { + test('it logs the application information', async () => { + mockApplicationVersion(); + await service.checkIfApplicationIsInstalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0' + ); + }); + + test('it does not log if useOldApi = true', async () => { + service = createExternalService( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + { ...snExternalServiceConfig['.servicenow'], useImportAPI: false } + ); + await service.checkIfApplicationIsInstalled(); + expect(requestMock).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 73b9fd45db5ce..cb030c7bb6933 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -77,7 +77,7 @@ export const createExternalService: ServiceFactory = ( }; const checkInstance = (res: AxiosResponse) => { - if ((res.status >= 200 || res.status < 400) && res.data.result == null) { + if (res.status >= 200 && res.status < 400 && res.data.result == null) { throw new Error( `There is an issue with your Service Now Instance. Please check ${ res.request?.connection?.servername ?? '' From 394acb40c12e74908812f85ef31c086f41859939 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 17:15:59 +0300 Subject: [PATCH 58/92] Fix i18n --- .../components/connectors/servicenow/translations.ts | 2 +- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - .../builtin_action_types/servicenow/translations.ts | 8 ++++---- .../components/actions_connectors_list.tsx | 3 ++- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts index 637e8f3302515..e371d272ef426 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -82,7 +82,7 @@ export const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( ); export const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( - 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', { defaultMessage: 'This connector type is deprecated. Create a new connector or update this connector', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 61dff1ffbb73c..3561fb328ea08 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25142,7 +25142,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "選択肢を取得できません", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "緊急", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "ServiceNow ITSMでインシデントを作成します。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 27f2bb701cac4..2684f159e242a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25570,7 +25570,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "无法获取选项", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "紧急性", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名", - "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "在 ServiceNow ITSM 中创建事件。", "xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index cc79d01ffed82..644e4d1e003df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -15,7 +15,7 @@ export const API_URL_LABEL = i18n.translate( ); export const API_URL_HELPTEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText', { defaultMessage: 'Include the full URL', } @@ -246,7 +246,7 @@ export const INSTALL = i18n.translate( ); export const INSTALLATION_CALLOUT_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutInfo', + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle', { defaultMessage: 'To use this connector, you must first install the Elastic App from the ServiceNow App Store', @@ -265,7 +265,7 @@ export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) => ); export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutInfo', + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText', { defaultMessage: 'Connector has been successfully migrated.', } @@ -293,7 +293,7 @@ export const SN_INSTANCE_LABEL = i18n.translate( ); export const UNKNOWN = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.snInstanceLabel', + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unknown', { defaultMessage: 'UNKNOWN', } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 163e963b521f1..39bb2ae200058 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -215,9 +215,10 @@ const ActionsConnectorsList: React.FunctionComponent = () => { { defaultMessage: 'Deprecated connector' } )} content={i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.missingSecretsDescription', + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.isLegacyDescription', { defaultMessage: 'Please update your connector' } )} + position="right" /> )} From 955b1a66e4f5734838e9767cc1a03d55bfa3fa95 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 28 Sep 2021 20:58:55 +0300 Subject: [PATCH 59/92] Fix cypress test --- .../cypress/integration/cases/connectors.spec.ts | 13 +++++++++++-- .../security_solution/cypress/objects/case.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index aa1bd7a5db5cc..a53e37f363d05 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getServiceNowConnector } from '../../objects/case'; +import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../objects/case'; import { SERVICE_NOW_MAPPING, TOASTER } from '../../screens/configure_cases'; @@ -43,8 +43,16 @@ describe('Cases connectors', () => { id: '123', owner: 'securitySolution', }; + + const snConnector = getServiceNowConnector(); + beforeEach(() => { cleanKibana(); + cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, { + statusCode: 200, + body: getServiceNowITSMHealthResponse(), + }); + cy.intercept('POST', '/api/actions/connector').as('createConnector'); cy.intercept('POST', '/api/cases/configure', (req) => { const connector = req.body.connector; @@ -52,6 +60,7 @@ describe('Cases connectors', () => { res.send(200, { ...configureResult, connector }); }); }).as('saveConnector'); + cy.intercept('GET', '/api/cases/configure', (req) => { req.reply((res) => { const resBody = @@ -77,7 +86,7 @@ describe('Cases connectors', () => { loginAndWaitForPageWithoutDateRange(CASES_URL); goToEditExternalConnection(); openAddNewConnectorOption(); - addServiceNowConnector(getServiceNowConnector()); + addServiceNowConnector(snConnector); cy.wait('@createConnector').then(({ response }) => { cy.wrap(response!.statusCode).should('eql', 200); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index af9b34f542046..b0bfdbf16c705 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -44,6 +44,14 @@ export interface IbmResilientConnectorOptions { incidentTypes: string[]; } +interface ServiceNowHealthResponse { + result: { + name: string; + scope: string; + version: string; + }; +} + export const getCase1 = (): TestCase => ({ name: 'This is the title of the case', tags: ['Tag1', 'Tag2'], @@ -60,6 +68,14 @@ export const getServiceNowConnector = (): Connector => ({ password: 'password', }); +export const getServiceNowITSMHealthResponse = (): ServiceNowHealthResponse => ({ + result: { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }, +}); + export const getJiraConnectorOptions = (): JiraConnectorOptions => ({ issueType: '10006', priority: 'High', From b250d234d4914711a4a879bfc0e43759237ba75b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 12:46:50 +0300 Subject: [PATCH 60/92] Improve ServiceNow intergration tests --- .../actions_simulators/server/plugin.ts | 13 +- .../server/servicenow_simulation.ts | 290 ++++++++---------- .../server/swimlane_simulation.ts | 2 +- .../builtin_action_types/servicenow.ts | 188 ++++++++---- 4 files changed, 259 insertions(+), 234 deletions(-) diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 997f36020af8c..ecfd8ef3b8e52 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -42,14 +42,6 @@ export function getAllExternalServiceSimulatorPaths(): string[] { const allPaths = Object.values(ExternalServiceSimulator).map((service) => getExternalServiceSimulatorPath(service) ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); - allPaths.push( - `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident/123` - ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`); - allPaths.push( - `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` - ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/createmeta`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); @@ -76,6 +68,10 @@ export async function getSwimlaneServer(): Promise { return await initSwimlane(); } +export async function getServiceNowServer(): Promise { + return await initServiceNow(); +} + interface FixtureSetupDeps { actions: ActionsPluginSetupContract; features: FeaturesPluginSetup; @@ -127,7 +123,6 @@ export class FixturePlugin implements Plugin, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, - }); - } - ); +export const initPlugin = async () => http.createServer(handler); - router.patch( - { - path: `${path}/api/now/v2/table/incident/{id}`, - options: { - authRequired: false, - }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' }, - }); +const sendResponse = (response: http.ServerResponse, data: any) => { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(data, null, 4)); +}; + +const handler = async (request: http.IncomingMessage, response: http.ServerResponse) => { + const buffers = []; + let data: Record = {}; + + if (request.method === 'POST') { + for await (const chunk of request) { + buffers.push(chunk); } - ); - router.get( - { - path: `${path}/api/now/v2/table/incident/{id}`, - options: { - authRequired: false, + data = JSON.parse(Buffer.concat(buffers).toString()); + } + + const pathName = request.url!; + + if (pathName === '/api/x_elas2_inc_int/elastic_api/health') { + return sendResponse(response, { + result: { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: { + }); + } + + // Import Set API: Create or update incident + if (pathName === '/api/now/import/x_elas2_inc_int_elastic_incident') { + const update = data?.elastic_incident_id != null; + return sendResponse(response, { + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: '/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', sys_id: '123', - number: 'INC01', - sys_created_on: '2020-03-10 12:24:20', - short_description: 'title', - description: 'description', }, - }); - } - ); + ], + }); + } - router.get( - { - path: `${path}/api/now/v2/table/sys_dictionary`, - options: { - authRequired: false, - }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: [ - { - column_label: 'Close notes', - mandatory: 'false', - max_length: '4000', - element: 'close_notes', - }, - { - column_label: 'Description', - mandatory: 'false', - max_length: '4000', - element: 'description', - }, - { - column_label: 'Short description', - mandatory: 'false', - max_length: '160', - element: 'short_description', - }, - ], - }); - } - ); + // Create incident + if (pathName === '/api/now/v2/table/incident') { + return sendResponse(response, { + result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, + }); + } - router.get( - { - path: `${path}/api/now/v2/table/sys_choice`, - options: { - authRequired: false, + // URLs of type /api/now/v2/table/incident/{id} + // GET incident, PATCH incident + if (pathName.includes('/api/now/v2/table/incident')) { + return sendResponse(response, { + result: { + sys_id: '123', + number: 'INC01', + sys_created_on: '2020-03-10 12:24:20', + sys_updated_on: '2020-03-10 12:24:20', }, - validate: {}, - }, - async function ( - context: RequestHandlerContext, - req: KibanaRequest, - res: KibanaResponseFactory - ): Promise> { - return jsonResponse(res, 200, { - result: [ - { - 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', - }, - ], - }); - } - ); -} + }); + } + + if (pathName.includes('/api/now/table/sys_dictionary')) { + return sendResponse(response, { + result: [ + { + column_label: 'Close notes', + mandatory: 'false', + max_length: '4000', + element: 'close_notes', + }, + { + column_label: 'Description', + mandatory: 'false', + max_length: '4000', + element: 'description', + }, + { + column_label: 'Short description', + mandatory: 'false', + max_length: '160', + element: 'short_description', + }, + ], + }); + } -function jsonResponse(res: KibanaResponseFactory, code: number, object?: Record) { - if (object == null) { - return res.custom({ - statusCode: code, - body: '', + if (pathName.includes('/api/now/table/sys_choice')) { + return sendResponse(response, { + result: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], }); } - return res.custom>({ body: object, statusCode: code }); -} + // Return an 400 error if endpoint is not supported + response.statusCode = 400; + response.setHeader('Content-Type', 'application/json'); + response.end('Not supported endpoint to request servicenow simulator'); +}; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts index afba550908ddc..97cbcbe7a60a6 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/swimlane_simulation.ts @@ -35,5 +35,5 @@ const handler = (request: http.IncomingMessage, response: http.ServerResponse) = // Return an 400 error if http method is not supported response.statusCode = 400; response.setHeader('Content-Type', 'application/json'); - response.end('Not supported http method to request slack simulator'); + response.end('Not supported http method to request swimlane simulator'); }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index d6196ee6ce312..ea6d12989d697 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -7,24 +7,22 @@ import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { - getExternalServiceSimulatorPath, - ExternalServiceSimulator, -} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default function servicenowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const configService = getService('config'); const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, }, secrets: { password: 'elastic', @@ -53,15 +51,36 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }, }; - let servicenowSimulatorURL: string = ''; - describe('ServiceNow', () => { - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } ); }); + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + describe('ServiceNow - Action Creation', () => { it('should return 200 when creating a servicenow action successfully', async () => { const { body: createdAction } = await supertest @@ -71,7 +90,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, }, secrets: mockServiceNow.secrets, }) @@ -84,7 +103,8 @@ export default function servicenowTest({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', is_missing_secrets: false, config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, }); @@ -99,11 +119,33 @@ export default function servicenowTest({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', is_missing_secrets: false, config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, }); }); + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction.config.isLegacy).to.be(false); + }); + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { await supertest .post('/api/actions/connector') @@ -155,7 +197,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow action', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, }, }) .expect(400) @@ -171,9 +213,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); describe('ServiceNow - Executor', () => { - let simulatedActionId: string; - let proxyServer: httpProxy | undefined; - let proxyHaveBeenCalled = false; before(async () => { const { body } = await supertest .post('/api/actions/connector') @@ -182,19 +221,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { name: 'A servicenow simulator', connector_type_id: '.servicenow', config: { - apiUrl: servicenowSimulatorURL, + apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, secrets: mockServiceNow.secrets, }); simulatedActionId = body.id; - - proxyServer = await getHttpProxyServer( - kibanaServer.resolveUrl('/'), - configService.get('kbnTestServer.serverArgs'), - () => { - proxyHaveBeenCalled = true; - } - ); }); describe('Validation', () => { @@ -377,31 +409,81 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); describe('Execution', () => { - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockServiceNow.params, - subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, - comments: [], + // New connectors + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, - }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: simulatedActionId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, - }, + }); + }); + }); + + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }, + }); }); }); @@ -453,12 +535,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); }); - - after(() => { - if (proxyServer) { - proxyServer.close(); - } - }); }); }); } From b57fd00a1d15323a506bdb7e91f917ed8a559e0b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 13:21:12 +0300 Subject: [PATCH 61/92] Fix cases integration tests --- .../case_api_integration/common/lib/utils.ts | 24 +++++++- .../tests/trial/cases/push_case.ts | 60 +++++++++---------- .../tests/trial/cases/push_case.ts | 31 +++++----- .../tests/trial/cases/push_case.ts | 33 +++++----- 4 files changed, 82 insertions(+), 66 deletions(-) diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 7367641d71585..f34d7398db0c2 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -6,6 +6,9 @@ */ import { omit } from 'lodash'; +import getPort from 'get-port'; +import http from 'http'; + import expect from '@kbn/expect'; import type { ApiResponse, estypes } from '@elastic/elasticsearch'; import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; @@ -58,6 +61,7 @@ import { User } from './authentication/types'; import { superUser } from './authentication/users'; import { ESCasesConfigureAttributes } from '../../../../plugins/cases/server/services/configure/types'; import { ESCaseAttributes } from '../../../../plugins/cases/server/services/cases/types'; +import { getServiceNowServer } from '../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; function toArray(input: T | T[]): T[] { if (Array.isArray(input)) { @@ -652,13 +656,13 @@ export const getCaseSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => export const createCaseWithConnector = async ({ supertest, configureReq = {}, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth = { user: superUser, space: null }, createCaseReq = getPostCaseRequest(), }: { supertest: SuperTest.SuperTest; - servicenowSimulatorURL: string; + serviceNowSimulatorURL: string; actionsRemover: ActionsRemover; configureReq?: Record; auth?: { user: User; space: string | null }; @@ -671,7 +675,7 @@ export const createCaseWithConnector = async ({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth, }); @@ -1220,3 +1224,17 @@ export const getAlertsAttachedToCase = async ({ return theCase; }; + +export const getServiceNowSimulationServer = async (): Promise<{ + server: http.Server; + url: string; +}> => { + const server = await getServiceNowServer(); + const port = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!server.listening) { + server.listen(port); + } + const url = `http://localhost:${port}`; + + return { server, url }; +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 0ea66d35b63b8..73e8f2ba851fc 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -7,6 +7,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import http from 'http'; + import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -32,11 +34,8 @@ import { getServiceNowConnector, getConnectorMappingsFromES, getCase, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { CaseConnector, CaseStatuses, @@ -55,17 +54,17 @@ import { // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -73,10 +72,14 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should push a case', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -95,18 +98,13 @@ export default ({ getService }: FtrProviderContext): void => { external_title: 'INC01', }); - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); + expect(external_url.includes('nav_to.do?uri=incident.do?sys_id=123')).to.equal(true); }); it('preserves the connector.id after pushing a case', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -121,7 +119,7 @@ export default ({ getService }: FtrProviderContext): void => { it('preserves the external_service.connector_id after updating the connector', async () => { const { postedCase, connector: pushConnector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -135,7 +133,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -175,7 +173,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -222,7 +220,7 @@ export default ({ getService }: FtrProviderContext): void => { it('pushes a comment appropriately', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq }); @@ -241,7 +239,7 @@ export default ({ getService }: FtrProviderContext): void => { closure_type: 'close-by-pushing', }, supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const theCase = await pushCase({ @@ -256,7 +254,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should create the correct user action', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); const pushedCase = await pushCase({ @@ -289,7 +287,7 @@ export default ({ getService }: FtrProviderContext): void => { connector_name: connector.name, external_id: '123', external_title: 'INC01', - external_url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + external_url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }); }); @@ -297,7 +295,7 @@ export default ({ getService }: FtrProviderContext): void => { it.skip('should push a collection case but not close it when closure_type: close-by-pushing', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, configureReq: { closure_type: 'close-by-pushing', @@ -337,7 +335,7 @@ export default ({ getService }: FtrProviderContext): void => { it('unhappy path = 409s when case is closed', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); await updateCase({ @@ -367,7 +365,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should push a case that the user has permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, }); @@ -383,7 +381,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), @@ -404,7 +402,7 @@ export default ({ getService }: FtrProviderContext): void => { } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: superUserSpace1Auth, }); @@ -422,7 +420,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case in a space that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: { user: superUser, space: 'space2' }, }); diff --git a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts index 6294400281b92..69d403ea15301 100644 --- a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts @@ -5,6 +5,8 @@ * 2.0. */ +import http from 'http'; + import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -13,11 +15,8 @@ import { pushCase, deleteAllCaseItems, createCaseWithConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { globalRead, noKibanaPrivileges, @@ -31,17 +30,17 @@ import { secOnlyDefaultSpaceAuth } from '../../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -49,12 +48,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + const supertestWithoutAuth = getService('supertestWithoutAuth'); it('should push a case that the user has permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -69,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not push a case that the user does not have permissions for', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), }); @@ -95,7 +98,7 @@ export default ({ getService }: FtrProviderContext): void => { } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); @@ -112,7 +115,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return a 404 when attempting to access a space', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts index 28b7fe6095507..dd9e881e8e08d 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts @@ -6,6 +6,7 @@ */ /* eslint-disable @typescript-eslint/naming-convention */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -17,27 +18,24 @@ import { deleteAllCaseItems, createCaseWithConnector, getAuthWithSuperUser, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const es = getService('es'); const authSpace1 = getAuthWithSuperUser(); describe('push_case', () => { const actionsRemover = new ActionsRemover(supertest); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -45,10 +43,14 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should push a case in space1', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: authSpace1, }); @@ -69,18 +71,13 @@ export default ({ getService }: FtrProviderContext): void => { external_title: 'INC01', }); - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); + expect(external_url.includes('nav_to.do?uri=incident.do?sys_id=123')).to.equal(true); }); it('should not push a case in a different space', async () => { const { postedCase, connector } = await createCaseWithConnector({ supertest, - servicenowSimulatorURL, + serviceNowSimulatorURL, actionsRemover, auth: authSpace1, }); From 224eca11a8e2e4161173050659c9e31b723e870b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 13:43:51 +0300 Subject: [PATCH 62/92] Fix triggers actions ui end to end test --- .../components/actions_connectors_list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 39bb2ae200058..04f2334f8e8fa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -177,7 +177,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => { item as UserConfiguredActionConnector, Record> ).config; const showLegacyTooltip = - itemConfig.isLegacy && + itemConfig?.isLegacy && // TODO: Remove when applications are certified ((ENABLE_NEW_SN_ITSM_CONNECTOR && item.actionTypeId === '.servicenow') || (ENABLE_NEW_SN_SIR_CONNECTOR && item.actionTypeId === '.servicenow-sir')); From d3550743fc32cd1dcc26db9a15d2e43647424909 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 15:22:58 +0300 Subject: [PATCH 63/92] Fix tests --- .../user_actions/get_all_user_actions.ts | 26 ++++++++++-------- .../tests/trial/configure/get_configure.ts | 25 ++++++++--------- .../tests/trial/configure/get_connectors.ts | 6 ++++- .../tests/trial/configure/patch_configure.ts | 26 +++++++++--------- .../tests/trial/configure/post_configure.ts | 24 +++++++++-------- .../tests/trial/cases/push_case.ts | 1 - .../tests/trial/configure/get_configure.ts | 27 ++++++++++--------- .../tests/trial/configure/get_connectors.ts | 6 ++++- .../tests/trial/configure/patch_configure.ts | 26 +++++++++--------- .../tests/trial/configure/post_configure.ts | 24 +++++++++-------- 10 files changed, 106 insertions(+), 85 deletions(-) diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts index 255a2a4ce28b5..fda2c8d361042 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; @@ -17,13 +18,10 @@ import { deleteConfiguration, getConfigurationRequest, getServiceNowConnector, + getServiceNowSimulationServer, } from '../../../../../common/lib/utils'; import { ObjectRemover as ActionsRemover } from '../../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getCreateConnectorUrl } from '../../../../../../../plugins/cases/common/utils/connectors_api'; // eslint-disable-next-line import/no-default-export @@ -31,15 +29,17 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_all_user_actions', () => { - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); + afterEach(async () => { await deleteCasesByESQuery(es); await deleteComments(es); @@ -48,13 +48,17 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { const { body: connector } = await supertest .post(getCreateConnectorUrl()) .set('kbn-xsrf', 'true') .send({ ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }) .expect(200); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts index ff8f1cff884af..404b63376daa4 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts @@ -5,14 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; - import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, @@ -22,6 +18,7 @@ import { getConfigurationRequest, removeServerGeneratedPropertiesFromSavedObject, getConfigurationOutput, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; @@ -29,27 +26,31 @@ import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_configure', () => { - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should return a configuration with mapping', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); actionsRemover.add('default', connector.id, 'action', 'actions'); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts index fb922f8d10243..c3e737464f19b 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts @@ -109,6 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', + isLegacy: false, }, isPreconfigured: false, isMissingSecrets: false, @@ -118,7 +119,10 @@ export default ({ getService }: FtrProviderContext): void => { id: sir.id, actionTypeId: '.servicenow-sir', name: 'ServiceNow Connector', - config: { apiUrl: 'http://some.non.existent.com' }, + config: { + apiUrl: 'http://some.non.existent.com', + isLegacy: false, + }, isPreconfigured: false, isMissingSecrets: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts index 789b68b19beb6..26eba77dd2576 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts @@ -5,13 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -22,6 +19,7 @@ import { updateConfiguration, getServiceNowConnector, createConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; @@ -29,16 +27,16 @@ import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('patch_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -46,12 +44,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should patch a configuration connector and create mappings', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); @@ -107,7 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts index 96ffcf4bc3f5c..077bfc5861322 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -22,22 +19,23 @@ import { createConfiguration, createConnector, getServiceNowConnector, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); describe('post_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -45,12 +43,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should create a configuration with mapping', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts index dd9e881e8e08d..bfb266e6f6c3a 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts @@ -7,7 +7,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import http from 'http'; - import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts index a142e6470ae93..4da44f08c6236 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts @@ -5,14 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; - import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, @@ -23,6 +19,7 @@ import { removeServerGeneratedPropertiesFromSavedObject, getConfigurationOutput, getAuthWithSuperUser, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { nullUser } from '../../../../common/lib/mock'; @@ -31,28 +28,32 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); describe('get_configure', () => { - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should return a configuration with a mapping from space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); @@ -107,7 +108,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts index 0301fa3a930cb..7b6848d1f301e 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts @@ -109,6 +109,7 @@ export default ({ getService }: FtrProviderContext): void => { name: 'ServiceNow Connector', config: { apiUrl: 'http://some.non.existent.com', + isLegacy: false, }, isPreconfigured: false, isMissingSecrets: false, @@ -118,7 +119,10 @@ export default ({ getService }: FtrProviderContext): void => { id: sir.id, actionTypeId: '.servicenow-sir', name: 'ServiceNow Connector', - config: { apiUrl: 'http://some.non.existent.com' }, + config: { + apiUrl: 'http://some.non.existent.com', + isLegacy: false, + }, isPreconfigured: false, isMissingSecrets: false, referencedByCount: 0, diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts index 14d0debe2ac17..ca362d13ae459 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts @@ -5,13 +5,10 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -24,6 +21,7 @@ import { createConnector, getAuthWithSuperUser, getActionsSpace, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { nullUser } from '../../../../common/lib/mock'; @@ -32,18 +30,18 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); const space = getActionsSpace(authSpace1.space); describe('patch_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -51,12 +49,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should patch a configuration connector and create mappings in space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); @@ -126,7 +128,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts index 7c5035193d465..b815278db5bd8 100644 --- a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ +import http from 'http'; import expect from '@kbn/expect'; import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; import { getConfigurationRequest, @@ -24,6 +21,7 @@ import { getServiceNowConnector, getAuthWithSuperUser, getActionsSpace, + getServiceNowSimulationServer, } from '../../../../common/lib/utils'; import { nullUser } from '../../../../common/lib/mock'; @@ -31,18 +29,18 @@ import { nullUser } from '../../../../common/lib/mock'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const kibanaServer = getService('kibanaServer'); const authSpace1 = getAuthWithSuperUser(); const space = getActionsSpace(authSpace1.space); describe('post_configure', () => { const actionsRemover = new ActionsRemover(supertest); - let servicenowSimulatorURL: string = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); + before(async () => { + const { server, url } = await getServiceNowSimulationServer(); + serviceNowServer = server; + serviceNowSimulatorURL = url; }); afterEach(async () => { @@ -50,12 +48,16 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); + after(async () => { + serviceNowServer.close(); + }); + it('should create a configuration with a mapping in space1', async () => { const connector = await createConnector({ supertest, req: { ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, + config: { apiUrl: serviceNowSimulatorURL }, }, auth: authSpace1, }); From 6782970fc3483f7b06f9010d9f5f35b0c3628757 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 17:30:56 +0300 Subject: [PATCH 64/92] Rename modal --- .../builtin_action_types/servicenow/servicenow_connectors.tsx | 4 ++-- ...tion_confirmation_modal.tsx => update_connector_modal.tsx} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/{migration_confirmation_modal.tsx => update_connector_modal.tsx} (96%) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 2af66b54b9885..6ffe3b5e70520 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -17,7 +17,7 @@ import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; import { isRESTApiError } from './helpers'; import { InstallationCallout } from './installation_callout'; -import { MigrationConfirmationModal } from './migration_confirmation_modal'; +import { UpdateConnectorModalComponent } from './update_connector_modal'; import { updateActionConnector } from '../../../lib/action_connector_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../../../actions/server/constants/connectors'; @@ -119,7 +119,7 @@ const ServiceNowConnectorFields: React.FC {showModal && ( - void; } -const MigrationConfirmationModalComponent: React.FC = ({ +const UpdateConnectorModalComponent: React.FC = ({ action, errors, readOnly, @@ -143,4 +143,4 @@ const MigrationConfirmationModalComponent: React.FC = ({ ); }; -export const MigrationConfirmationModal = memo(MigrationConfirmationModalComponent); +export const UpdateConnectorModal = memo(UpdateConnectorModalComponent); From 0d48027a6d4dc4060d6c45426d8e2a3f060ca6ab Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 17:48:53 +0300 Subject: [PATCH 65/92] Show error message on modal --- .../servicenow/servicenow_connectors.tsx | 12 +++++++++--- .../servicenow/update_connector_modal.tsx | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 6ffe3b5e70520..5b845cb38b95e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -17,7 +17,7 @@ import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; import { isRESTApiError } from './helpers'; import { InstallationCallout } from './installation_callout'; -import { UpdateConnectorModalComponent } from './update_connector_modal'; +import { UpdateConnectorModal } from './update_connector_modal'; import { updateActionConnector } from '../../../lib/action_connector_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../../../actions/server/constants/connectors'; @@ -52,6 +52,9 @@ const ServiceNowConnectorFields: React.FC(null); const getApplicationInfo = useCallback(async () => { + setApplicationRequired(false); + setApplicationInfoErrorMsg(null); + try { const res = await fetchAppInfo(action); if (isRESTApiError(res)) { @@ -119,8 +122,9 @@ const ServiceNowConnectorFields: React.FC {showModal && ( - - {applicationRequired && } + {applicationRequired && !isLegacy && ( + + )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx index 49cc0b37f3aa8..b9d660f16dff7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx @@ -25,6 +25,7 @@ import { ActionConnectorFieldsProps } from '../../../../../public/types'; import { ServiceNowActionConnector } from './types'; import { Credentials } from './credentials'; import { isFieldInvalid } from './helpers'; +import { ApplicationRequiredCallout } from './application_required_callout'; const title = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', @@ -64,9 +65,10 @@ const warningMessage = i18n.translate( interface Props { action: ActionConnectorFieldsProps['action']; + applicationInfoErrorMsg: string | null; errors: ActionConnectorFieldsProps['errors']; - readOnly: boolean; isLoading: boolean; + readOnly: boolean; editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; editActionConfig: ActionConnectorFieldsProps['editActionConfig']; onCancel: () => void; @@ -75,9 +77,10 @@ interface Props { const UpdateConnectorModalComponent: React.FC = ({ action, + applicationInfoErrorMsg, errors, - readOnly, isLoading, + readOnly, editActionSecrets, editActionConfig, onCancel, @@ -126,6 +129,13 @@ const UpdateConnectorModalComponent: React.FC = ({ {warningMessage} + + + {applicationInfoErrorMsg && ( + + )} + + {cancelButtonText} From b051285285609bbd290a8aa0f646e1ee904a7974 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 17:58:55 +0300 Subject: [PATCH 66/92] Create useOldConnector helper --- .../servicenow/helpers.ts | 28 ++++++++++++++++++- .../servicenow/servicenow_connectors.tsx | 19 ++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index a1dd6bb08a369..b354a87c52c4f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -6,8 +6,13 @@ */ import { EuiSelectOption } from '@elastic/eui'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../actions/server/constants/connectors'; import { IErrorObject } from '../../../../../public/types'; -import { AppInfo, Choice, RESTApiError } from './types'; +import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types'; export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => choices.map((choice) => ({ value: choice.value, text: choice.label })); @@ -19,3 +24,24 @@ export const isFieldInvalid = ( field: string | undefined, error: string | IErrorObject | string[] ): boolean => error !== undefined && error.length > 0 && field !== undefined; + +// TODO: Remove when the application are certified +export const useOldConnector = (connector: ServiceNowActionConnector) => { + if ( + ENABLE_NEW_SN_ITSM_CONNECTOR && + connector.actionTypeId === '.servicenow' && + connector.config.isLegacy + ) { + return true; + } + + if ( + ENABLE_NEW_SN_SIR_CONNECTOR && + connector.actionTypeId === '.servicenow-sir' && + connector.config.isLegacy + ) { + return true; + } + + return false; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 5b845cb38b95e..9ba9792189a06 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -15,12 +15,10 @@ import { useKibana } from '../../../../common/lib/kibana'; import { DeprecatedCallout } from './deprecated_callout'; import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; -import { isRESTApiError } from './helpers'; +import { isRESTApiError, useOldConnector } from './helpers'; import { InstallationCallout } from './installation_callout'; import { UpdateConnectorModal } from './update_connector_modal'; import { updateActionConnector } from '../../../lib/action_connector_api'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../../../actions/server/constants/connectors'; import { Credentials } from './credentials'; const ServiceNowConnectorFields: React.FC> = @@ -38,8 +36,9 @@ const ServiceNowConnectorFields: React.FC { - if (ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy) { + if (!isOldConnector) { await getApplicationInfo(); } - }, [getApplicationInfo, isLegacy]); + }, [getApplicationInfo, isOldConnector]); const afterActionConnectorSave = useCallback(async () => { // TODO: Implement @@ -134,10 +133,8 @@ const ServiceNowConnectorFields: React.FC )} - {ENABLE_NEW_SN_ITSM_CONNECTOR && !isLegacy && } - {ENABLE_NEW_SN_ITSM_CONNECTOR && isLegacy && ( - - )} + {!isOldConnector && } + {isOldConnector && } - {applicationRequired && !isLegacy && ( + {applicationRequired && !isOldConnector && ( )} From c4b18652e740ce80c6acf7563b6fae897bc9c7d4 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 18:09:49 +0300 Subject: [PATCH 67/92] Show the update incident toggle only on new connectors --- .../servicenow/helpers.ts | 8 +++-- .../servicenow/servicenow_connectors.tsx | 4 +-- .../servicenow/servicenow_itsm_params.tsx | 32 +++++++++++-------- .../servicenow/servicenow_sir_params.tsx | 28 +++++++++------- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index b354a87c52c4f..e47745400af14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -25,8 +25,12 @@ export const isFieldInvalid = ( error: string | IErrorObject | string[] ): boolean => error !== undefined && error.length > 0 && field !== undefined; -// TODO: Remove when the application are certified -export const useOldConnector = (connector: ServiceNowActionConnector) => { +// TODO: Remove when the applications are certified +export const enableLegacyConnector = (connector: ServiceNowActionConnector) => { + if (connector == null) { + return false; + } + if ( ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow' && diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 9ba9792189a06..2bddeb14f1227 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -15,7 +15,7 @@ import { useKibana } from '../../../../common/lib/kibana'; import { DeprecatedCallout } from './deprecated_callout'; import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; -import { isRESTApiError, useOldConnector } from './helpers'; +import { isRESTApiError, enableLegacyConnector } from './helpers'; import { InstallationCallout } from './installation_callout'; import { UpdateConnectorModal } from './update_connector_modal'; import { updateActionConnector } from '../../../lib/action_connector_api'; @@ -38,7 +38,7 @@ const ServiceNowConnectorFields: React.FC @@ -273,17 +277,19 @@ const ServiceNowParamsFields: React.FunctionComponent< /> - - - - - + {!isOldConnector && ( + + + + + + )} @@ -301,15 +305,17 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< label={i18n.COMMENTS_LABEL} /> - - - + {!isOldConnector && ( + + + + )} ); }; From 5ae1eb415cb4b94f7915427127d8d80b43d97f6c Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 19:17:31 +0300 Subject: [PATCH 68/92] Add observables for old connectors --- .../servicenow/api_sir.ts | 80 ++++++++++++++++--- .../builtin_action_types/servicenow/index.ts | 1 + .../builtin_action_types/servicenow/types.ts | 1 + 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index ba0b79312894c..3b231b6dbce2a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -13,6 +13,7 @@ import { ExternalServiceSIR, ObservableTypes, PushToServiceApiHandlerArgs, + PushToServiceApiParamsSIR, PushToServiceResponse, } from './types'; @@ -43,16 +44,66 @@ const combineObservables = (a: string | string[], b: string | string[]): string return Array.isArray(a) && Array.isArray(b) ? [...a, ...b] : `${a},${b}`; }; +const observablesToString = (obs: string | string[] | null): string | null => { + if (Array.isArray(obs)) { + return obs.join(','); + } + + return obs; +}; + +const prepareParams = ( + isLegacy: boolean, + params: PushToServiceApiParamsSIR +): PushToServiceApiParamsSIR => { + if (isLegacy) { + /** + * The schema has change to accept an array of observables + * or a string. In the case of a legacy connector we need to + * convert the observables to a string + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: observablesToString(params.incident.dest_ip), + malware_hash: observablesToString(params.incident.malware_hash), + malware_url: observablesToString(params.incident.malware_url), + source_ip: observablesToString(params.incident.source_ip), + }, + }; + } + + /** + * For non legacy connectors the observables + * will be added in a different call. + * They need to be set to null when sending the fields + * to ServiceNow + */ + return { + ...params, + incident: { + ...params.incident, + dest_ip: null, + malware_hash: null, + malware_url: null, + source_ip: null, + }, + }; +}; + const pushToServiceHandler = async ({ externalService, params, + config, secrets, commentFieldKey, logger, }: PushToServiceApiHandlerArgs): Promise => { const res = await api.pushToService({ externalService, - params, + params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR), + config, secrets, commentFieldKey, logger, @@ -66,17 +117,26 @@ const pushToServiceHandler = async ({ source_ip: sourceIP, }, } = params as ExecutorSubActionPushParamsSIR; - const sirExternalService = externalService as ExternalServiceSIR; - const obsWithType: Array<[string | string[], ObservableTypes]> = [ - [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], - [malwareHash ?? [], ObservableTypes.sha256], - [malwareUrl ?? [], ObservableTypes.url], - ]; + /** + * Add bulk observables is only available for new connectors + * Old connectors gonna add their observables + * through the pushToService call. + */ + + if (!config.isLegacy) { + const sirExternalService = externalService as ExternalServiceSIR; + + const obsWithType: Array<[string | string[], ObservableTypes]> = [ + [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], + [malwareHash ?? [], ObservableTypes.sha256], + [malwareUrl ?? [], ObservableTypes.url], + ]; - const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); - if (observables.length > 0) { - await sirExternalService.bulkAddObservableToIncident(observables, res.id); + const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); + if (observables.length > 0) { + await sirExternalService.bulkAddObservableToIncident(observables, res.id); + } } return res; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index eb5f9fca14965..29907381d45da 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -173,6 +173,7 @@ async function executor( data = await api.pushToService({ externalService, params: pushToServiceParams, + config, secrets, logger, commentFieldKey: externalServiceConfig.commentFieldKey, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index c5b5fb64b8019..b889069e6278d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -132,6 +132,7 @@ export type ServiceNowSIRIncident = Omit< export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { params: PushToServiceApiParams; + config: Record; secrets: Record; logger: Logger; commentFieldKey: string; From 6ddd323887f6681cd9f9314a84d6583da08444e9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 19:54:39 +0300 Subject: [PATCH 69/92] Fix error when obs are empty --- .../servicenow/api_sir.ts | 8 +++-- .../builtin_action_types/servicenow/schema.ts | 32 +++++++++---------- .../servicenow/servicenow_sir_params.tsx | 14 ++++---- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index 3b231b6dbce2a..6b11b36d92393 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isString } from 'lodash'; +import { isEmpty, isString } from 'lodash'; import { ExecutorSubActionPushParamsSIR, @@ -29,10 +29,14 @@ const formatObservables = (observables: string | string[], type: ObservableTypes */ const obsAsArray = Array.isArray(observables) ? observables : observables.split(SPLIT_REGEX); const uniqueObservables = new Set(obsAsArray); - return [...uniqueObservables].map((obs) => ({ value: obs, type })); + return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type })); }; const combineObservables = (a: string | string[], b: string | string[]): string | string[] => { + if (isEmpty(a) && isEmpty(b)) { + return []; + } + if (isString(a) && Array.isArray(b)) { return [...b, ...a.split(SPLIT_REGEX)]; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index eb6f33810c0e7..dab68bb9d3e9d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -59,22 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({ incident: schema.object({ ...CommonAttributes, - dest_ip: schema.oneOf([ - schema.nullable(schema.string()), - schema.nullable(schema.arrayOf(schema.string())), - ]), - malware_hash: schema.oneOf([ - schema.nullable(schema.string()), - schema.nullable(schema.arrayOf(schema.string())), - ]), - malware_url: schema.oneOf([ - schema.nullable(schema.string()), - schema.nullable(schema.arrayOf(schema.string())), - ]), - source_ip: schema.oneOf([ - schema.nullable(schema.string()), - schema.nullable(schema.arrayOf(schema.string())), - ]), + dest_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_hash: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + malware_url: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), + source_ip: schema.oneOf( + [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))], + { defaultValue: null } + ), priority: schema.nullable(schema.string()), }), comments: CommentsSchema, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 498e4be0880bc..d8f01334495f9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -33,12 +33,12 @@ const defaultFields: Fields = { priority: [], }; -const valuesToString = (value: string | string[] | null): string | null => { +const valuesToString = (value: string | string[] | null): string | undefined => { if (Array.isArray(value)) { return value.join(','); } - return value; + return value ?? undefined; }; const ServiceNowSIRParamsFields: React.FunctionComponent< @@ -189,7 +189,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'short_description'} - inputTargetValue={incident?.short_description ?? undefined} + inputTargetValue={incident?.short_description} errors={errors['subActionParams.incident.short_description'] as string[]} /> @@ -200,7 +200,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'source_ip'} - inputTargetValue={valuesToString(incident?.source_ip) ?? undefined} + inputTargetValue={valuesToString(incident?.source_ip)} /> @@ -210,7 +210,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'dest_ip'} - inputTargetValue={valuesToString(incident?.dest_ip) ?? undefined} + inputTargetValue={valuesToString(incident?.dest_ip)} /> @@ -220,7 +220,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'malware_url'} - inputTargetValue={valuesToString(incident?.malware_url) ?? undefined} + inputTargetValue={valuesToString(incident?.malware_url)} /> @@ -230,7 +230,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction={editSubActionProperty} messageVariables={messageVariables} paramsProperty={'malware_hash'} - inputTargetValue={valuesToString(incident?.malware_hash) ?? undefined} + inputTargetValue={valuesToString(incident?.malware_hash)} /> From a5bec1faab3d7f460f0764f6fd626895973f0399 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 20:14:47 +0300 Subject: [PATCH 70/92] Enable SIR for alerts --- .../plugins/security_solution/common/constants.ts | 1 + .../sections/action_connector_form/action_form.tsx | 14 ++------------ .../public/common/constants/index.ts | 2 -- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 092875c57fbd0..8191985bb99db 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -281,6 +281,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ '.swimlane', '.webhook', '.servicenow', + '.servicenow-sir', '.jira', '.resilient', '.teams', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 4dcf501fa0023..eda0b99e859a6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -34,11 +34,7 @@ import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; -import { - VIEW_LICENSE_OPTIONS_LINK, - DEFAULT_HIDDEN_ACTION_TYPES, - DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES, -} from '../../../common/constants'; +import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { ActionGroup, AlertActionParam } from '../../../../../alerting/common'; import { useKibana } from '../../../common/lib/kibana'; import { DefaultActionParamsGetter } from '../../lib/get_defaults_for_action_params'; @@ -237,15 +233,9 @@ export const ActionForm = ({ .list() /** * TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. - * TODO: Need to decide about ServiceNow SIR connector. * If actionTypes are set, hidden connectors are filtered out. Otherwise, they are not. */ - .filter( - ({ id }) => - actionTypes ?? - (!DEFAULT_HIDDEN_ACTION_TYPES.includes(id) && - !DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES.includes(id)) - ) + .filter(({ id }) => actionTypes ?? !DEFAULT_HIDDEN_ACTION_TYPES.includes(id)) .filter((item) => actionTypesIndex[item.id]) .filter((item) => !!item.actionParamsFields) .sort((a, b) => diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index c2523dd59821d..9e490945e2261 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -12,5 +12,3 @@ export { builtInGroupByTypes } from './group_by_types'; export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions'; // TODO: Remove when cases connector is available across Kibana. Issue: https://github.com/elastic/kibana/issues/82502. export const DEFAULT_HIDDEN_ACTION_TYPES = ['.case']; -// Action types included in this array will be hidden only from the alert's action type node list -export const DEFAULT_HIDDEN_ONLY_ON_ALERTS_ACTION_TYPES = ['.servicenow-sir']; From 3ae7b4dd0f4a1059c9c4049c446087fc6bc96998 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 29 Sep 2021 20:37:29 +0300 Subject: [PATCH 71/92] Fix types --- .../server/builtin_action_types/servicenow/api.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index bd67399138bb6..7e88e906dbfa9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -25,6 +25,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -57,6 +58,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -78,6 +80,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, commentFieldKey: 'comments', @@ -106,6 +109,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -149,6 +153,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -193,6 +198,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params: apiParams, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -221,6 +227,7 @@ describe('api', () => { const res = await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -239,6 +246,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -266,6 +274,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'comments', @@ -308,6 +317,7 @@ describe('api', () => { await api.pushToService({ externalService, params, + config: {}, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', From 7f33adc9baf97334904f4a4f8e43193e32716d1e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 30 Sep 2021 13:25:37 +0300 Subject: [PATCH 72/92] Improve combineObservables --- .../servicenow/api_sir.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index 6b11b36d92393..1c98283dd2edf 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -33,18 +33,38 @@ const formatObservables = (observables: string | string[], type: ObservableTypes }; const combineObservables = (a: string | string[], b: string | string[]): string | string[] => { + // Both are empty if (isEmpty(a) && isEmpty(b)) { return []; } + /** + * One of a or b can be empty + * but not both + */ + if (isEmpty(a)) { + return b; + } + + if (isEmpty(b)) { + return a; + } + + /** + * Neither of a or b is empty + * a and b can be either a string or an array + */ if (isString(a) && Array.isArray(b)) { - return [...b, ...a.split(SPLIT_REGEX)]; + return [...a.split(SPLIT_REGEX), ...b]; } if (isString(b) && Array.isArray(a)) { - return [...a, ...b.split(SPLIT_REGEX)]; + return [...b.split(SPLIT_REGEX), ...a]; } + /** + * a and b are both an array or a string + */ return Array.isArray(a) && Array.isArray(b) ? [...a, ...b] : `${a},${b}`; }; From bdd26ce4f6e33aa622a99b8229bc3e1a8b9cf996 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 30 Sep 2021 14:42:29 +0300 Subject: [PATCH 73/92] Add test for the sir api --- .../servicenow/api_sir.test.ts | 299 ++++++++++++++++++ .../servicenow/api_sir.ts | 24 +- .../builtin_action_types/servicenow/mocks.ts | 70 +++- 3 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts new file mode 100644 index 0000000000000..b39943bfe96ce --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -0,0 +1,299 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '../../../../../../src/core/server'; +import { externalServiceSIRMock, sirParams } from './mocks'; +import { ExternalServiceSIR, ObservableTypes } from './types'; +import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir'; +let mockedLogger: jest.Mocked; + +describe('api_sir', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceSIRMock.create(); + jest.clearAllMocks(); + }); + + describe('combineObservables', () => { + test('it returns an empty array when both arguments are an empty array', async () => { + expect(combineObservables([], [])).toEqual([]); + }); + + test('it returns an empty array when both arguments are an empty string', async () => { + expect(combineObservables('', '')).toEqual([]); + }); + + test('it returns an empty array when a="" and b=[]', async () => { + expect(combineObservables('', [])).toEqual([]); + }); + + test('it returns an empty array when a=[] and b=""', async () => { + expect(combineObservables([], '')).toEqual([]); + }); + + test('it returns a if b is empty', async () => { + expect(combineObservables('a', '')).toEqual('a'); + }); + + test('it returns b if a is empty', async () => { + expect(combineObservables([], ['b'])).toEqual(['b']); + }); + + test('it combines two strings with comma delimiter', async () => { + expect(combineObservables('a,b', 'c,d')).toEqual('a,b,c,d'); + }); + + test('it combines two arrays', async () => { + expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']); + }); + + test('it combines a string with an array', async () => { + expect(combineObservables('a', ['b'])).toEqual(['a', 'b']); + }); + + test('it combines an array with a string ', async () => { + expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']); + }); + + test('it combines a "," concatenated string', async () => { + expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "|" concatenated string', async () => { + expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a space concatenated string', async () => { + expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\n" concatenated string', async () => { + expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\r" concatenated string', async () => { + expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines a "\\t" concatenated string', async () => { + expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']); + expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']); + }); + + test('it combines two strings with different delimiter', async () => { + expect(combineObservables('a|b|c', 'd e f')).toEqual('a,b,c,d,e,f'); + }); + }); + + describe('formatObservables', () => { + test('it formats string observables correctly', async () => { + expect(formatObservables('a,b,c', ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'b' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it formats array observables correctly', async () => { + expect(formatObservables(['a', 'b', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'b' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it removes duplicates from string observables correctly', async () => { + expect(formatObservables('a,a,c', ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it removes duplicates from array observables correctly', async () => { + expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it removes empty string observables correctly', async () => { + expect(formatObservables('', ObservableTypes.ip4)).toEqual([]); + expect(formatObservables('a,,c', ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + + test('it removes empty array observables correctly', async () => { + expect(formatObservables([], ObservableTypes.ip4)).toEqual([]); + expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([ + { type: 'ipv4-addr', value: 'a' }, + { type: 'ipv4-addr', value: 'c' }, + ]); + }); + }); + + describe('prepareParams', () => { + test('it prepares the params correctly when the connector is legacy', async () => { + expect(prepareParams(true, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: '192.168.1.1,192.168.1.3', + source_ip: '192.168.1.2,192.168.1.4', + malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + malware_url: 'https://example.com', + }, + }); + }); + + test('it prepares the params correctly when the connector is not legacy', async () => { + expect(prepareParams(false, sirParams)).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + + test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => { + expect( + prepareParams(true, { + ...sirParams, + incident: { + ...sirParams.incident, + // @ts-expect-error + dest_ip: undefined, + // @ts-expect-error + source_ip: undefined, + // @ts-expect-error + malware_hash: undefined, + // @ts-expect-error + malware_url: undefined, + }, + }) + ).toEqual({ + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }, + }); + }); + }); + + describe('pushToService', () => { + test('it creates an incident correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + const res = await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('it adds observables correctly', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith( + [ + { type: 'ipv4-addr', value: '192.168.1.1' }, + { type: 'ipv4-addr', value: '192.168.1.3' }, + { type: 'ipv4-addr', value: '192.168.1.2' }, + { type: 'ipv4-addr', value: '192.168.1.4' }, + { + type: 'SHA256', + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + }, + { type: 'URL', value: 'https://example.com' }, + ], + // createIncident mock returns this incident id + 'incident-1' + ); + }); + + test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => { + const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: true }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + + test('it does not call bulkAddObservableToIncident if there are no observables', async () => { + const params = { + ...sirParams, + incident: { + ...sirParams.incident, + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + externalId: null, + }, + }; + + await apiSIR.pushToService({ + externalService, + params, + config: { isLegacy: false }, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + + expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index 1c98283dd2edf..ca07fddfc209c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -20,8 +20,9 @@ import { import { api } from './api'; const SPLIT_REGEX = /[ ,|\r\n\t]+/; +const SPLIT_REGEX_GLOBAL = /[ ,|\r\n\t]+/g; -const formatObservables = (observables: string | string[], type: ObservableTypes) => { +export const formatObservables = (observables: string | string[], type: ObservableTypes) => { /** * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. * Before the application the observables were being sent to ServiceNow as a concatenated string with @@ -32,7 +33,10 @@ const formatObservables = (observables: string | string[], type: ObservableTypes return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type })); }; -const combineObservables = (a: string | string[], b: string | string[]): string | string[] => { +export const combineObservables = ( + a: string | string[], + b: string | string[] +): string | string[] => { // Both are empty if (isEmpty(a) && isEmpty(b)) { return []; @@ -58,25 +62,29 @@ const combineObservables = (a: string | string[], b: string | string[]): string return [...a.split(SPLIT_REGEX), ...b]; } - if (isString(b) && Array.isArray(a)) { - return [...b.split(SPLIT_REGEX), ...a]; + if (Array.isArray(a) && isString(b)) { + return [...a, ...b.split(SPLIT_REGEX)]; } /** * a and b are both an array or a string */ - return Array.isArray(a) && Array.isArray(b) ? [...a, ...b] : `${a},${b}`; + return Array.isArray(a) && Array.isArray(b) + ? [...a, ...b] + : isString(a) && isString(b) + ? `${a.replace(SPLIT_REGEX_GLOBAL, ',')},${b.replace(SPLIT_REGEX_GLOBAL, ',')}` + : []; }; -const observablesToString = (obs: string | string[] | null): string | null => { +const observablesToString = (obs: string | string[] | null | undefined): string | null => { if (Array.isArray(obs)) { return obs.join(','); } - return obs; + return obs ?? null; }; -const prepareParams = ( +export const prepareParams = ( isLegacy: boolean, params: PushToServiceApiParamsSIR ): PushToServiceApiParamsSIR => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index fa93088b1a73e..5831ba71ea0dd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { ExternalService, ExecutorSubActionPushParams } from './types'; +import { + ExternalService, + ExecutorSubActionPushParams, + PushToServiceApiParamsSIR, + ExternalServiceSIR, +} from './types'; export const serviceNowCommonFields = [ { @@ -110,10 +115,44 @@ const createMock = (): jest.Mocked => { return service; }; +const createSIRMock = (): jest.Mocked => { + const service = { + ...createMock(), + addObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + value: 'https://example.com', + observable_sys_id: '3', + }) + ), + bulkAddObservableToIncident: jest.fn().mockImplementation(() => + Promise.resolve([ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ]) + ), + }; + + return service; +}; + const externalServiceMock = { create: createMock, }; +const externalServiceSIRMock = { + create: createSIRMock, +}; + const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', @@ -139,6 +178,33 @@ const executorParams: ExecutorSubActionPushParams = { ], }; +const sirParams: PushToServiceApiParamsSIR = { + incident: { + externalId: 'incident-3', + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'alertID', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + }, + ], +}; + const apiParams = executorParams; -export { externalServiceMock, executorParams, apiParams }; +export { externalServiceMock, executorParams, apiParams, sirParams, externalServiceSIRMock }; From 2119f85f83cc93b187b773438fd59e40d9a44260 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 30 Sep 2021 15:15:48 +0300 Subject: [PATCH 74/92] Add test for the sir service --- .../builtin_action_types/servicenow/mocks.ts | 26 +++- .../servicenow/service_sir.test.ts | 129 ++++++++++++++++++ .../servicenow/service_sir.ts | 2 +- 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 5831ba71ea0dd..e099e66e76240 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -10,6 +10,8 @@ import { ExecutorSubActionPushParams, PushToServiceApiParamsSIR, ExternalServiceSIR, + Observable, + ObservableTypes, } from './types'; export const serviceNowCommonFields = [ @@ -205,6 +207,28 @@ const sirParams: PushToServiceApiParamsSIR = { ], }; +const observables: Observable[] = [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + type: ObservableTypes.sha256, + }, + { + value: '127.0.0.1', + type: ObservableTypes.ip4, + }, + { + value: 'https://example.com', + type: ObservableTypes.url, + }, +]; + const apiParams = executorParams; -export { externalServiceMock, executorParams, apiParams, sirParams, externalServiceSIRMock }; +export { + externalServiceMock, + executorParams, + apiParams, + sirParams, + externalServiceSIRMock, + observables, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts new file mode 100644 index 0000000000000..c47fb506013d9 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios from 'axios'; + +import { createExternalServiceSIR } from './service_sir'; +import * as utils from '../lib/axios_utils'; +import { ExternalServiceSIR } from './types'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { observables } from './mocks'; +import { snExternalServiceConfig } from './config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' }, + }, + })); + +const getAddObservablesResponse = () => [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, +]; + +const mockAddObservablesResponse = (single: boolean) => { + const res = getAddObservablesResponse(); + requestMock.mockImplementation(() => ({ + data: { + result: single ? res[0] : res, + }, + })); +}; + +const expectAddObservables = (single: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + const url = single + ? 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables' + : 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk'; + + const data = single ? observables[0] : observables; + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url, + method: 'post', + data, + }); +}; + +describe('ServiceNow SIR service', () => { + let service: ExternalServiceSIR; + + beforeEach(() => { + service = createExternalServiceSIR( + { + config: { apiUrl: 'https://dev102283.service-now.com/' }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + snExternalServiceConfig['.servicenow-sir'] + ) as ExternalServiceSIR; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bulkAddObservableToIncident', () => { + test('it adds multiple observables correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(false); + + const res = await service.bulkAddObservableToIncident(observables, 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()); + expectAddObservables(false); + }); + + test('it adds a single observable correctly', async () => { + mockApplicationVersion(); + mockAddObservablesResponse(true); + + const res = await service.addObservableToIncident(observables[0], 'incident-1'); + expect(res).toEqual(getAddObservablesResponse()[0]); + expectAddObservables(true); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts index 8f3e27b355c0e..fc8d8cc555bc8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts @@ -60,7 +60,7 @@ export const createExternalServiceSIR: ServiceFactory = ( }); snService.checkInstance(res); - return { ...res.data.result }; + return res.data.result; }; const addObservableToIncident = async ( From 6777b94ca0ef5cb6a617c2e808c825115f23c645 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 30 Sep 2021 18:34:24 +0300 Subject: [PATCH 75/92] Add documentation --- docs/developer/plugin-list.asciidoc | 3 +- docs/management/action-types.asciidoc | 6 +- .../action-types/servicenow-sir.asciidoc | 89 +++++++++++++++ .../action-types/servicenow.asciidoc | 14 ++- .../images/servicenow-sir-params-test.png | Bin 190659 -> 46762 bytes x-pack/plugins/actions/README.md | 105 ++++++++++++++---- x-pack/plugins/cases/README.md | 12 +- .../servicenow/translations.ts | 10 +- 8 files changed, 198 insertions(+), 41 deletions(-) create mode 100644 docs/management/connectors/action-types/servicenow-sir.asciidoc diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index edc1821f3b223..ff3c351c7861e 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -357,7 +357,8 @@ The plugin exposes the static DefaultEditorController class to consume. |{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases] -|Case management in Kibana +|[![Issues][issues-shield]][issues-url] +[![Pull Requests][pr-shield]][pr-url] |{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud] diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 92adbaf97d8c5..93d0ee3d2cab6 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -35,10 +35,14 @@ a| <> | Add a message to a Kibana log. -a| <> +a| <> | Create an incident in ServiceNow. +a| <> + +| Create a security incident in ServiceNow. + a| <> | Send a message to a Slack channel or user. diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc new file mode 100644 index 0000000000000..4556746284d5b --- /dev/null +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -0,0 +1,89 @@ +[role="xpack"] +[[servicenow-sir-action-type]] +=== ServiceNow connector and action +++++ +ServiceNow SecOps +++++ + +The ServiceNow SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow security incidents. + +[float] +[[servicenow-sir-connector-configuration]] +==== Connector configuration + +ServiceNow SecOps connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +The ServiceNow user requires at minimum read, create, and update access to the Security Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render. + +[float] +[[servicenow-sir-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-servicenow-sir-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-servicenow-sir: + name: preconfigured-servicenow-connector-type + actionTypeId: .servicenow-sir + config: + apiUrl: https://dev94428.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +Config defines information for the connector type. + +`apiUrl`:: An address that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`username`:: A string that corresponds to *Username*. +`password`:: A string that corresponds to *Password*. Should be stored in the <>. + +[float] +[[define-servicenow-sir-ui]] +==== Define connector in Stack Management + +Define ServiceNow SecOps connector properties. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector] + +Test ServiceNow SecOps action parameters. + +[role="screenshot"] +image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow SecOps params test] + +[float] +[[servicenow-sir-action-configuration]] +==== Action configuration + +ServiceNow SecOps actions have the following configuration properties. + +Short description:: A short description for the incident, used for searching the contents of the knowledge base. +Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident. +Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident. +Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident. +Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident. +Priority:: The priority of the incident. +Category:: The category of the incident. +Subcategory:: The subcategory of the incident. +Description:: The details about the incident. +Additional comments:: Additional information for the client, such as how to troubleshoot the issue. + +[float] +[[configuring-servicenow-sir]] +==== Configure ServiceNow SecOps + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index 3a4134cbf982e..cf5244a9e3f9e 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -2,16 +2,16 @@ [[servicenow-action-type]] === ServiceNow connector and action ++++ -ServiceNow +ServiceNow ITSM ++++ -The ServiceNow connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. +The ServiceNow ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents. [float] [[servicenow-connector-configuration]] ==== Connector configuration -ServiceNow connectors have the following configuration properties. +ServiceNow ITSM connectors have the following configuration properties. Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action. URL:: ServiceNow instance URL. @@ -55,12 +55,12 @@ Secrets defines sensitive information for the connector type. [[define-servicenow-ui]] ==== Define connector in Stack Management -Define ServiceNow connector properties. +Define ServiceNow ITSM connector properties. [role="screenshot"] image::management/connectors/images/servicenow-connector.png[ServiceNow connector] -Test ServiceNow action parameters. +Test ServiceNow ITSM action parameters. [role="screenshot"] image::management/connectors/images/servicenow-params-test.png[ServiceNow params test] @@ -69,11 +69,13 @@ image::management/connectors/images/servicenow-params-test.png[ServiceNow params [[servicenow-action-configuration]] ==== Action configuration -ServiceNow actions have the following configuration properties. +ServiceNow ITSM actions have the following configuration properties. Urgency:: The extent to which the incident resolution can delay. Severity:: The severity of the incident. Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. +Category:: The category of the incident. +Subcategory:: The category of the incident. Short description:: A short description for the incident, used for searching the contents of the knowledge base. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png index 16ea83c60b3c328f4580bdd08d4f2544e8c8cffc..80103a4272bfacd0a6ccb23bb138b98efe327f51 100644 GIT binary patch literal 46762 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ì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%0AwWoSpN;xE=Y5Ue-?=mI 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 diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index b19e89a599840..0d66c9d30f8b9 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -33,29 +33,36 @@ Table of Contents - [actionsClient.execute(options)](#actionsclientexecuteoptions) - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - - [ServiceNow](#servicenow) + - [ServiceNow ITSM](#servicenow-itsm) - [`params`](#params) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice) - [`subActionParams (getFields)`](#subactionparams-getfields) - [`subActionParams (getIncident)`](#subactionparams-getincident) - [`subActionParams (getChoices)`](#subactionparams-getchoices) - - [Jira](#jira) + - [ServiceNow Sec Ops](#servicenow-sec-ops) - [`params`](#params-1) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [`subActionParams (getFields)`](#subactionparams-getfields-1) - [`subActionParams (getIncident)`](#subactionparams-getincident-1) + - [`subActionParams (getChoices)`](#subactionparams-getchoices-1) + - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-) + - [Jira](#jira) + - [`params`](#params-2) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) + - [`subActionParams (getIncident)`](#subactionparams-getincident-2) - [`subActionParams (issueTypes)`](#subactionparams-issuetypes) - [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype) - [`subActionParams (issues)`](#subactionparams-issues) - [`subActionParams (issue)`](#subactionparams-issue) - - [`subActionParams (getFields)`](#subactionparams-getfields-1) - - [IBM Resilient](#ibm-resilient) - - [`params`](#params-2) - - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [`subActionParams (getFields)`](#subactionparams-getfields-2) + - [IBM Resilient](#ibm-resilient) + - [`params`](#params-3) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3) + - [`subActionParams (getFields)`](#subactionparams-getfields-3) - [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes) - [`subActionParams (severity)`](#subactionparams-severity) - [Swimlane](#swimlane) - - [`params`](#params-3) + - [`params`](#params-4) - [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ In addition to the documented configurations, several built in action type offer additional `params` configurations. -## ServiceNow +## ServiceNow ITSM -The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. +The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. ### `params` | Property | Description | Type | @@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib The following table describes the properties of the `incident` object. -| Property | Description | Type | -| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | -| short_description | The title of the incident. | string | -| description | The description of the incident. | string _(optional)_ | -| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | -| severity | The severity in ServiceNow. | string _(optional)_ | -| urgency | The urgency in ServiceNow. | string _(optional)_ | -| impact | The impact in ServiceNow. | string _(optional)_ | -| category | The category in ServiceNow. | string _(optional)_ | -| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| Property | Description | Type | +| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- | +| short_description | The title of the incident. | string | +| description | The description of the incident. | string _(optional)_ | +| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ | +| severity | The severity in ServiceNow. | string _(optional)_ | +| urgency | The urgency in ServiceNow. | string _(optional)_ | +| impact | The impact in ServiceNow. | string _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the incident. | string _(optional)_ | +| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ | #### `subActionParams (getFields)` @@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`. #### `subActionParams (getChoices)` -| Property | Description | Type | -| -------- | ------------------------------------------------------------ | -------- | -| fields | An array of fields. Example: `[priority, category, impact]`. | string[] | +| Property | Description | Type | +| -------- | -------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[category, impact]`. | string[] | --- +## ServiceNow Sec Ops + +The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. + +### `params` + +| Property | Description | Type | +| --------------- | -------------------------------------------------------------------------------------------------- | ------ | +| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string | +| subActionParams | The parameters of the subaction. | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- | +| incident | The ServiceNow security incident. | object | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ | + +The following table describes the properties of the `incident` object. + +| Property | Description | Type | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| short_description | The title of the security incident. | string | +| description | The description of the security incident. | string _(optional)_ | +| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ | +| priority | The priority in ServiceNow. | string _(optional)_ | +| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ | +| category | The category in ServiceNow. | string _(optional)_ | +| subcategory | The subcategory in ServiceNow. | string _(optional)_ | +| correlation_id | The correlation id of the security incident. | string _(optional)_ | +| correlation_display | The correlation display of the security incident. | string _(optional)_ | + +#### `subActionParams (getFields)` + +No parameters for the `getFields` subaction. Provide an empty object `{}`. + +#### `subActionParams (getIncident)` + +| Property | Description | Type | +| ---------- | ---------------------------------------------- | ------ | +| externalId | The ID of the security incident in ServiceNow. | string | + + +#### `subActionParams (getChoices)` + +| Property | Description | Type | +| -------- | ---------------------------------------------------- | -------- | +| fields | An array of fields. Example: `[priority, category]`. | string[] | +--- ## Jira The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available. diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index f894ca23dfbf0..f28926eb52052 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -1,9 +1,9 @@ -Case management in Kibana +# Case management in Kibana [![Issues][issues-shield]][issues-url] -[![Pull Requests][pr-shield]][pr-url] +[![Pull Requests][pr-shield]][pr-url] -# Cases Plugin Docs +# Docs ![Cases Logo][cases-logo] @@ -288,9 +288,9 @@ Connectors of type (`.none`) should have the `fields` attribute set to `null`. -[pr-shield]: https://img.shields.io/github/issues-pr/elangosundar/awesome-README-templates?style=for-the-badge -[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+label%3AFeature%3ACases+-is%3Adraft+is%3Aopen+ -[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge +[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge +[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22 +[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge [issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases [cases-logo]: images/logo.png [configure-img]: images/configure.png diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 644e4d1e003df..90292a35a88df 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -102,7 +102,7 @@ export const TITLE_REQUIRED = i18n.translate( export const SOURCE_IP_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle', { - defaultMessage: 'Source IP', + defaultMessage: 'Source IPs', } ); @@ -116,7 +116,7 @@ export const SOURCE_IP_HELP_TEXT = i18n.translate( export const DEST_IP_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle', { - defaultMessage: 'Destination IP', + defaultMessage: 'Destination IPs', } ); @@ -158,7 +158,7 @@ export const COMMENTS_LABEL = i18n.translate( export const MALWARE_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle', { - defaultMessage: 'Malware URL', + defaultMessage: 'Malware URLs', } ); @@ -172,14 +172,14 @@ export const MALWARE_URL_HELP_TEXT = i18n.translate( export const MALWARE_HASH_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', { - defaultMessage: 'Malware Hash', + defaultMessage: 'Malware Hashes', } ); export const MALWARE_HASH_HELP_TEXT = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText', { - defaultMessage: 'List of malware hashed (comma, or pipe delimited)', + defaultMessage: 'List of malware hashes (comma, or pipe delimited)', } ); From 196c13e063297046dcee8d912db8740e9e7c784a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 1 Oct 2021 15:12:41 +0300 Subject: [PATCH 76/92] PR feedback --- .../builtin_action_types/servicenow/types.ts | 4 ++-- .../builtin_action_types/servicenow/utils.ts | 3 +-- .../builtin_action_types/servicenow/helpers.ts | 18 +++--------------- 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index b889069e6278d..8b8f4538ee2b8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { AxiosResponse } from 'axios'; +import { AxiosError, AxiosResponse } from 'axios'; import { TypeOf } from '@kbn/config-schema'; import { ExecutorParamsSchemaITSM, @@ -195,7 +195,7 @@ export interface ExternalServiceCommentResponse { } type TypeNullOrUndefined = T | null | undefined; -export interface ResponseError { +export interface ResponseError extends AxiosError { error: TypeNullOrUndefined<{ message: TypeNullOrUndefined; detail: TypeNullOrUndefined; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 4fae985e60af3..4a926f484f1e3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -27,8 +27,7 @@ const createErrorMessage = (errorResponse: ResponseError): string => { return error != null ? `${error?.message}: ${error?.detail}` : 'unknown'; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createServiceError = (error: any, message: string) => +export const createServiceError = (error: ResponseError, message: string) => new Error( getErrorMessage( i18n.SERVICENOW, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index e47745400af14..02552566843dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -27,25 +27,13 @@ export const isFieldInvalid = ( // TODO: Remove when the applications are certified export const enableLegacyConnector = (connector: ServiceNowActionConnector) => { - if (connector == null) { - return false; - } - - if ( - ENABLE_NEW_SN_ITSM_CONNECTOR && - connector.actionTypeId === '.servicenow' && - connector.config.isLegacy - ) { + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { return true; } - if ( - ENABLE_NEW_SN_SIR_CONNECTOR && - connector.actionTypeId === '.servicenow-sir' && - connector.config.isLegacy - ) { + if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') { return true; } - return false; + return connector.config.isLegacy; }; From baa78346ddba375c06288e620fa36108e466395b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 1 Oct 2021 15:24:54 +0300 Subject: [PATCH 77/92] Improve cases deprecated callouts --- .../servicenow/deprecated_callout.tsx | 9 +- .../servicenow_itsm_case_fields.tsx | 191 +++++++------- .../servicenow/servicenow_sir_case_fields.tsx | 233 +++++++++--------- 3 files changed, 226 insertions(+), 207 deletions(-) diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx index 34c6a9547a090..66aa0d1ff7c74 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx @@ -10,11 +10,16 @@ import { EuiCallOut } from '@elastic/eui'; import * as i18n from './translations'; -const DeprecatedCalloutComponent: React.FC = () => { +interface Props { + isEdit: boolean; +} + +const DeprecatedCalloutComponent: React.FC = ({ isEdit }) => { + const color = isEdit ? 'danger' : 'warning'; return ( diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index c11cd18ef27a2..1dc32cd1d3c84 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -41,7 +41,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); @@ -152,103 +152,110 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } }, [category, impact, onChange, severity, subcategory, urgency]); - return isEdit ? ( -
- {showMappingWarning && ( + return ( + <> + {showConnectorWarning && ( - + )} - - - - onChangeCb('urgency', e.target.value)} - /> - - - - - - - - onChangeCb('severity', e.target.value)} - /> - - - - - onChangeCb('impact', e.target.value)} - /> - - - - - - - onChange({ ...fields, category: e.target.value, subcategory: null })} - /> - - - - - onChangeCb('subcategory', e.target.value)} + {isEdit ? ( +
+ + + + onChangeCb('urgency', e.target.value)} + /> + + + + + + + + onChangeCb('severity', e.target.value)} + /> + + + + + onChangeCb('impact', e.target.value)} + /> + + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null }) + } + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + + -
-
-
-
- ) : ( - <> - {showMappingWarning && } - +
+
+ )} ); }; diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index aa5334ea41302..470b429d1cb3e 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -43,7 +43,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); const onChangeCb = useCallback( ( @@ -168,124 +168,131 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< } }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); - return isEdit ? ( -
- {showMappingWarning && ( + return ( + <> + {showConnectorWarning && ( - + )} - - - - <> - - - onChangeCb('destIp', e.target.checked)} - /> - - - onChangeCb('sourceIp', e.target.checked)} - /> - - - - - onChangeCb('malwareUrl', e.target.checked)} - /> - - - onChangeCb('malwareHash', e.target.checked)} - /> - - - - - - - - - - onChangeCb('priority', e.target.value)} - /> - - - - - - - onChange({ ...fields, category: e.target.value, subcategory: null })} - /> - - - - - onChangeCb('subcategory', e.target.value)} + {isEdit ? ( +
+ + + + <> + + + onChangeCb('destIp', e.target.checked)} + /> + + + onChangeCb('sourceIp', e.target.checked)} + /> + + + + + onChangeCb('malwareUrl', e.target.checked)} + /> + + + onChangeCb('malwareHash', e.target.checked)} + /> + + + + + + + + + + onChangeCb('priority', e.target.value)} + /> + + + + + + + + onChange({ ...fields, category: e.target.value, subcategory: null }) + } + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + + -
-
-
-
- ) : ( - <> - {showMappingWarning && } - +
+
+ )} ); }; From 33ce5967d9087c08b0d7de00fbb7dbf382a8abe4 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 1 Oct 2021 18:14:09 +0300 Subject: [PATCH 78/92] Improve observables format --- .../servicenow/api_sir.test.ts | 34 +++-------- .../servicenow/api_sir.ts | 56 +++++-------------- 2 files changed, 22 insertions(+), 68 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts index b39943bfe96ce..0ca3dce65cbd9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -37,15 +37,15 @@ describe('api_sir', () => { }); test('it returns a if b is empty', async () => { - expect(combineObservables('a', '')).toEqual('a'); + expect(combineObservables('a', '')).toEqual(['a']); }); test('it returns b if a is empty', async () => { expect(combineObservables([], ['b'])).toEqual(['b']); }); - test('it combines two strings with comma delimiter', async () => { - expect(combineObservables('a,b', 'c,d')).toEqual('a,b,c,d'); + test('it combines two strings', async () => { + expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']); }); test('it combines two arrays', async () => { @@ -91,19 +91,11 @@ describe('api_sir', () => { }); test('it combines two strings with different delimiter', async () => { - expect(combineObservables('a|b|c', 'd e f')).toEqual('a,b,c,d,e,f'); + expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); }); }); describe('formatObservables', () => { - test('it formats string observables correctly', async () => { - expect(formatObservables('a,b,c', ObservableTypes.ip4)).toEqual([ - { type: 'ipv4-addr', value: 'a' }, - { type: 'ipv4-addr', value: 'b' }, - { type: 'ipv4-addr', value: 'c' }, - ]); - }); - test('it formats array observables correctly', async () => { expect(formatObservables(['a', 'b', 'c'], ObservableTypes.ip4)).toEqual([ { type: 'ipv4-addr', value: 'a' }, @@ -112,13 +104,6 @@ describe('api_sir', () => { ]); }); - test('it removes duplicates from string observables correctly', async () => { - expect(formatObservables('a,a,c', ObservableTypes.ip4)).toEqual([ - { type: 'ipv4-addr', value: 'a' }, - { type: 'ipv4-addr', value: 'c' }, - ]); - }); - test('it removes duplicates from array observables correctly', async () => { expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([ { type: 'ipv4-addr', value: 'a' }, @@ -126,16 +111,11 @@ describe('api_sir', () => { ]); }); - test('it removes empty string observables correctly', async () => { - expect(formatObservables('', ObservableTypes.ip4)).toEqual([]); - expect(formatObservables('a,,c', ObservableTypes.ip4)).toEqual([ - { type: 'ipv4-addr', value: 'a' }, - { type: 'ipv4-addr', value: 'c' }, - ]); + test('it formats an empty array correctly', async () => { + expect(formatObservables([], ObservableTypes.ip4)).toEqual([]); }); - test('it removes empty array observables correctly', async () => { - expect(formatObservables([], ObservableTypes.ip4)).toEqual([]); + test('it removes empty observables correctly', async () => { expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([ { type: 'ipv4-addr', value: 'a' }, { type: 'ipv4-addr', value: 'c' }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index ca07fddfc209c..326bb79a0e708 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -20,60 +20,34 @@ import { import { api } from './api'; const SPLIT_REGEX = /[ ,|\r\n\t]+/; -const SPLIT_REGEX_GLOBAL = /[ ,|\r\n\t]+/g; -export const formatObservables = (observables: string | string[], type: ObservableTypes) => { +export const formatObservables = (observables: string[], type: ObservableTypes) => { /** * ServiceNow accepted formats are: comma, new line, tab, or pipe separators. * Before the application the observables were being sent to ServiceNow as a concatenated string with * delimiter. With the application the format changed to an array of observables. */ - const obsAsArray = Array.isArray(observables) ? observables : observables.split(SPLIT_REGEX); - const uniqueObservables = new Set(obsAsArray); + const uniqueObservables = new Set(observables); return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type })); }; -export const combineObservables = ( - a: string | string[], - b: string | string[] -): string | string[] => { - // Both are empty - if (isEmpty(a) && isEmpty(b)) { +const obsAsArray = (obs: string | string[]): string[] => { + if (isEmpty(obs)) { return []; } - /** - * One of a or b can be empty - * but not both - */ - if (isEmpty(a)) { - return b; - } - - if (isEmpty(b)) { - return a; + if (isString(obs)) { + return obs.split(SPLIT_REGEX); } - /** - * Neither of a or b is empty - * a and b can be either a string or an array - */ - if (isString(a) && Array.isArray(b)) { - return [...a.split(SPLIT_REGEX), ...b]; - } + return obs; +}; - if (Array.isArray(a) && isString(b)) { - return [...a, ...b.split(SPLIT_REGEX)]; - } +export const combineObservables = (a: string | string[], b: string | string[]): string[] => { + const first = obsAsArray(a); + const second = obsAsArray(b); - /** - * a and b are both an array or a string - */ - return Array.isArray(a) && Array.isArray(b) - ? [...a, ...b] - : isString(a) && isString(b) - ? `${a.replace(SPLIT_REGEX_GLOBAL, ',')},${b.replace(SPLIT_REGEX_GLOBAL, ',')}` - : []; + return [...first, ...second]; }; const observablesToString = (obs: string | string[] | null | undefined): string | null => { @@ -159,10 +133,10 @@ const pushToServiceHandler = async ({ if (!config.isLegacy) { const sirExternalService = externalService as ExternalServiceSIR; - const obsWithType: Array<[string | string[], ObservableTypes]> = [ + const obsWithType: Array<[string[], ObservableTypes]> = [ [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4], - [malwareHash ?? [], ObservableTypes.sha256], - [malwareUrl ?? [], ObservableTypes.url], + [obsAsArray(malwareHash ?? []), ObservableTypes.sha256], + [obsAsArray(malwareUrl ?? []), ObservableTypes.url], ]; const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat(); From e2b6de22037d1d75479f0af87f0a5ce5219f04af Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 1 Oct 2021 20:47:32 +0300 Subject: [PATCH 79/92] Add integration tests for SIR --- .../alerting_api_integration/common/config.ts | 1 + .../server/servicenow_simulation.ts | 47 +- .../builtin_action_types/servicenow.ts | 685 ++++++++++-------- 3 files changed, 412 insertions(+), 321 deletions(-) diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 87eb866b14fa5..0618d379dc77d 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -34,6 +34,7 @@ const enabledActionTypes = [ '.swimlane', '.server-log', '.servicenow', + '.servicenow-sir', '.jira', '.resilient', '.slack', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index d91ec51e9cd45..20b7f754463bb 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -29,7 +29,7 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo const pathName = request.url!; - if (pathName === '/api/x_elas2_inc_int/elastic_api/health') { + if (pathName.includes('elastic_api/health')) { return sendResponse(response, { result: { name: 'Elastic', @@ -40,7 +40,10 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo } // Import Set API: Create or update incident - if (pathName === '/api/now/import/x_elas2_inc_int_elastic_incident') { + if ( + pathName.includes('x_elas2_inc_int_elastic_incident') || + pathName.includes('x_elas2_inc_int_elastic_incident') + ) { const update = data?.elastic_incident_id != null; return sendResponse(response, { import_set: 'ISET01', @@ -60,7 +63,10 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo } // Create incident - if (pathName === '/api/now/v2/table/incident') { + if ( + pathName === '/api/now/v2/table/incident' || + pathName === '/api/now/v2/table/sn_si_incident' + ) { return sendResponse(response, { result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, }); @@ -68,7 +74,10 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo // URLs of type /api/now/v2/table/incident/{id} // GET incident, PATCH incident - if (pathName.includes('/api/now/v2/table/incident')) { + if ( + pathName.includes('/api/now/v2/table/incident') || + pathName.includes('/api/now/v2/table/sn_si_incident') + ) { return sendResponse(response, { result: { sys_id: '123', @@ -79,6 +88,36 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo }); } + // Add multiple observables + if (pathName.includes('/observables/bulk')) { + return sendResponse(response, { + result: [ + { + value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', + observable_sys_id: '1', + }, + { + value: '127.0.0.1', + observable_sys_id: '2', + }, + { + value: 'https://example.com', + observable_sys_id: '3', + }, + ], + }); + } + + // Add single observables + if (pathName.includes('/observables')) { + return sendResponse(response, { + result: { + value: '127.0.0.1', + observable_sys_id: '2', + }, + }); + } + if (pathName.includes('/api/now/table/sys_dictionary')) { return sendResponse(response, { result: [ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index ea6d12989d697..7412138750817 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -15,44 +15,11 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export -export default function servicenowTest({ getService }: FtrProviderContext) { +export default function serviceNowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - const mockServiceNow = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - isLegacy: false, - }, - secrets: { - password: 'elastic', - username: 'changeme', - }, - params: { - subAction: 'pushToService', - subActionParams: { - incident: { - description: 'a description', - externalId: null, - impact: '1', - severity: '1', - short_description: 'a title', - urgency: '1', - category: 'software', - subcategory: 'software', - }, - comments: [ - { - comment: 'first comment', - commentId: '456', - }, - ], - }, - }, - }; - describe('ServiceNow', () => { - let simulatedActionId = ''; let serviceNowSimulatorURL: string = ''; let serviceNowServer: http.Server; let proxyServer: httpProxy | undefined; @@ -81,18 +48,21 @@ export default function servicenowTest({ getService }: FtrProviderContext) { } }); - describe('ServiceNow - Action Creation', () => { + const testConnectorCreation = ( + connectorWithParams: Record, + connectorType: string + ) => { it('should return 200 when creating a servicenow action successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, }, - secrets: mockServiceNow.secrets, + secrets: connectorWithParams.secrets, }) .expect(200); @@ -100,7 +70,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { id: createdAction.id, is_preconfigured: false, name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, is_missing_secrets: false, config: { apiUrl: serviceNowSimulatorURL, @@ -116,7 +86,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { id: fetchedAction.id, is_preconfigured: false, name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, is_missing_secrets: false, config: { apiUrl: serviceNowSimulatorURL, @@ -131,11 +101,11 @@ export default function servicenowTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, }, - secrets: mockServiceNow.secrets, + secrets: connectorWithParams.secrets, }) .expect(200); @@ -152,7 +122,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, config: {}, }) .expect(400) @@ -172,11 +142,11 @@ export default function servicenowTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, config: { apiUrl: 'http://servicenow.mynonexistent.com', }, - secrets: mockServiceNow.secrets, + secrets: connectorWithParams.secrets, }) .expect(400) .then((resp: any) => { @@ -195,7 +165,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow action', - connector_type_id: '.servicenow', + connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, }, @@ -210,331 +180,412 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); }); - }); + }; + + const testExecuteValidation = ( + connectorWithParams: Record, + connectorType: string + ) => { + let connectorId: string = ''; - describe('ServiceNow - Executor', () => { before(async () => { const { body } = await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow simulator', - connector_type_id: '.servicenow', + connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, isLegacy: false, }, - secrets: mockServiceNow.secrets, + secrets: connectorWithParams.secrets, }); - simulatedActionId = body.id; + + connectorId = body.id; }); - describe('Validation', () => { - it('should handle failing with a simulated success without action', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: {}, - }) - .then((resp: any) => { - expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); - expect(resp.body.connector_id).to.eql(simulatedActionId); - expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - // Node.js 12 oddity: - // - // The first time after the server is booted, the error message will be: - // - // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) - // - // After this, the error will be: - // - // Cannot destructure property 'value' of 'undefined' as it is undefined. - // - // The error seems to come from the exact same place in the code based on the - // exact same circumstances: - // - // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 - // - // What triggers the error is that the `handleError` function expects its 2nd - // argument to be an object containing a `valids` property of type array. - // - // In this test the object does not contain a `valids` property, so hence the - // error. - // - // Why the error message isn't the same in all scenarios is unknown to me and - // could be a bug in V8. - expect(resp.body.message).to.match( - /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ - ); + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(connectorId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circumstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', }); - }); + }); + }); - it('should handle failing with a simulated success without unsupported action', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'non-supported' }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', - }); + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', }); - }); + }); + }); - it('should handle failing with a simulated success without subActionParams', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'pushToService' }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', - }); + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', }); - }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + incident: { + ...connectorWithParams.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ comment: 'boo' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + incident: { + ...connectorWithParams.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + }; + + const testExecute = (connectorWithParams: Record, connectorType: string) => { + const tableName = connectorType === '.servicenow-sir' ? 'sn_si_incident' : 'incident'; + let connectorId: string = ''; + + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: connectorWithParams.secrets, + }); - it('should handle failing with a simulated success without title', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + connectorId = body.id; + }); + + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...connectorWithParams.params, subActionParams: { - savedObjectId: 'success', + incident: connectorWithParams.params.subActionParams.incident, + comments: [], }, }, }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', - }); - }); + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: connectorId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, + }, + }); }); + }); - it('should handle failing with a simulated success without commentId', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ - params: { - ...mockServiceNow.params, - subActionParams: { - incident: { - ...mockServiceNow.params.subActionParams.incident, - short_description: 'success', - }, - comments: [{ comment: 'boo' }], - }, + name: 'A servicenow simulator', + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', - }); + secrets: connectorWithParams.secrets, }); + + connectorId = body.id; }); - it('should handle failing with a simulated success without comment message', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...mockServiceNow.params, + ...connectorWithParams.params, subActionParams: { - incident: { - ...mockServiceNow.params.subActionParams.incident, - short_description: 'success', - }, - comments: [{ commentId: 'success' }], + incident: connectorWithParams.params.subActionParams.incident, + comments: [], }, }, }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', - }); - }); - }); - - describe('getChoices', () => { - it('should fail when field is not provided', async () => { - await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - subAction: 'getChoices', - subActionParams: {}, - }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: simulatedActionId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]', - }); - }); + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: connectorId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, + }, }); }); }); - describe('Execution', () => { - // New connectors - describe('Import set API', () => { - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockServiceNow.params, - subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, - comments: [], - }, - }, - }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: simulatedActionId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, }, - }); + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: connectorId, + 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', + }, + ], }); }); + }); + }; - // Legacy connectors - describe('Table API', () => { - before(async () => { - const { body } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow simulator', - connector_type_id: '.servicenow', - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: true, - }, - secrets: mockServiceNow.secrets, - }); - simulatedActionId = body.id; - }); + describe('ServiceNow ITSM', () => { + const connectorWithParams = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + description: 'a description', + externalId: null, + impact: '1', + severity: '1', + short_description: 'a title', + urgency: '1', + category: 'software', + subcategory: 'software', + }, + comments: [ + { + comment: 'first comment', + commentId: '456', + }, + ], + }, + }, + }; - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...mockServiceNow.params, - subActionParams: { - incident: mockServiceNow.params.subActionParams.incident, - comments: [], - }, - }, - }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: simulatedActionId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + testConnectorCreation(connectorWithParams, '.servicenow'); + testExecuteValidation(connectorWithParams, '.servicenow'); + testExecute(connectorWithParams, '.servicenow'); + }); + + describe('ServiceNow SecOps', () => { + const connectorWithParams = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + externalId: null, + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'alertID', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + comment: 'first comment', + commentId: '456', }, - }); - }); - }); + ], + }, + }, + }; - describe('getChoices', () => { - it('should get choices', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - subAction: 'getChoices', - subActionParams: { fields: ['priority'] }, - }, - }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: simulatedActionId, - data: [ - { - dependent_value: '', - label: '1 - Critical', - value: '1', - }, - { - dependent_value: '', - label: '2 - High', - value: '2', - }, - { - dependent_value: '', - label: '3 - Moderate', - value: '3', - }, - { - dependent_value: '', - label: '4 - Low', - value: '4', - }, - { - dependent_value: '', - label: '5 - Planning', - value: '5', - }, - ], - }); - }); - }); - }); + testConnectorCreation(connectorWithParams, '.servicenow-sir'); + testExecuteValidation(connectorWithParams, '.servicenow-sir'); + testExecute(connectorWithParams, '.servicenow-sir'); }); }); } From 1951495c60e186e49d5adfdc8210cc845a8a3d45 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 4 Oct 2021 12:12:46 +0300 Subject: [PATCH 80/92] Fix doc error --- docs/management/connectors/index.asciidoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 033b1c3ac150e..536d05705181d 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[] include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/servicenow.asciidoc[] +include::action-types/servicenow-sir.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] From dd9977cd5d1762fc17b69b03247627c76af707ae Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 4 Oct 2021 14:33:43 +0300 Subject: [PATCH 81/92] Add config tests --- .../servicenow/config.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts new file mode 100644 index 0000000000000..babd360cbcb82 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { snExternalServiceConfig } from './config'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('config', () => { + test('ITSM: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); + + test('SIR: the config are correct', async () => { + const snConfig = snExternalServiceConfig['.servicenow-sir']; + expect(snConfig).toEqual({ + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + }); + }); +}); From 3932eb32917463625122956664d13664aaff2df8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 4 Oct 2021 14:34:18 +0300 Subject: [PATCH 82/92] Add getIncident tests --- .../servicenow/api.test.ts | 19 +++++++++++++++++++ .../builtin_action_types/servicenow/mocks.ts | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 7e88e906dbfa9..7076a3e8f1471 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -375,4 +375,23 @@ describe('api', () => { expect(res).toEqual(serviceNowChoices); }); }); + + describe('getIncident', () => { + test('it gets the incident correctly', async () => { + const res = await api.getIncident({ + externalService, + params: { + externalId: 'incident-1', + }, + }); + expect(res).toEqual({ + description: 'description from servicenow', + id: 'incident-1', + pushedDate: '2020-03-10T12:24:20.000Z', + short_description: 'title from servicenow', + title: 'INC01', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index e099e66e76240..1d4734ae53576 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -81,6 +81,10 @@ const createMock = (): jest.Mocked => { getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)), getIncident: jest.fn().mockImplementation(() => Promise.resolve({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', short_description: 'title from servicenow', description: 'description from servicenow', }) From 91ced672940460a131f7d464c4b276cbf523f187 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 4 Oct 2021 14:35:42 +0300 Subject: [PATCH 83/92] Add util tests --- .../builtin_action_types/servicenow/types.ts | 5 +- .../servicenow/utils.test.ts | 84 +++++++++++++++++++ .../builtin_action_types/servicenow/utils.ts | 4 +- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 8b8f4538ee2b8..ecca1e55e0fec 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -195,7 +195,8 @@ export interface ExternalServiceCommentResponse { } type TypeNullOrUndefined = T | null | undefined; -export interface ResponseError extends AxiosError { + +export interface ServiceNowError { error: TypeNullOrUndefined<{ message: TypeNullOrUndefined; detail: TypeNullOrUndefined; @@ -203,6 +204,8 @@ export interface ResponseError extends AxiosError { status: TypeNullOrUndefined; } +export type ResponseError = AxiosError; + export interface ImportSetApiResponseSuccess { import_set: string; staging_table: string; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts new file mode 100644 index 0000000000000..ce44200191ab6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AxiosError } from 'axios'; +import { prepareIncident, createServiceError, getPushedDate } from './utils'; + +/** + * The purpose of this test is to + * prevent developers from accidentally + * change important configuration values + * such as the scope or the import set table + * of our ServiceNow application + */ + +describe('utils', () => { + describe('prepareIncident', () => { + test('it prepares the incident correctly when useOldApi=false', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(false, incident); + expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' }); + }); + + test('it prepares the incident correctly when useOldApi=true', async () => { + const incident = { short_description: 'title', description: 'desc' }; + const newIncident = prepareIncident(true, incident); + expect(newIncident).toEqual(incident); + }); + }); + + describe('createServiceError', () => { + test('it creates an error when the response is null', async () => { + const error = new Error('An error occurred'); + // @ts-expect-error + expect(createServiceError(error, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown' + ); + }); + + test('it creates an error with response correctly', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: { message: 'Denied', detail: 'no access' } } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access' + ); + }); + + test('it creates an error correctly when the ServiceNow error is null', async () => { + const axiosError = { + message: 'An error occurred', + response: { data: { error: null } }, + } as AxiosError; + + expect(createServiceError(axiosError, 'Unable to do action').message).toBe( + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown' + ); + }); + }); + + describe('getPushedDate', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('it formats the date correctly if timestamp is provided', async () => { + expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z'); + }); + + test('it formats the date correctly if timestamp is not provided', async () => { + expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 4a926f484f1e3..66341cfa4326c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Incident, PartialIncident, ResponseError } from './types'; +import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types'; import { FIELD_PREFIX } from './config'; import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; import * as i18n from './translations'; @@ -18,7 +18,7 @@ export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): {} as Incident ); -const createErrorMessage = (errorResponse: ResponseError): string => { +const createErrorMessage = (errorResponse?: ServiceNowError): string => { if (errorResponse == null) { return 'unknown'; } From 139ede78f222e8a811d291dab94c2247977f2923 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 4 Oct 2021 14:35:56 +0300 Subject: [PATCH 84/92] Add migration tests --- .../saved_objects/actions_migrations.test.ts | 57 +++++++++++++++++++ .../saved_objects/actions_migrations.ts | 9 ++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index c094109a43d97..9f8e62c77e3a7 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -165,6 +165,47 @@ describe('successful migrations', () => { }); expect(migratedAction).toEqual(action); }); + + test('set isLegacy config property for .servicenow', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow(); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('set isLegacy config property for .servicenow-sir', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' }); + const migratedAction = migration716(action, context); + + expect(migratedAction).toEqual({ + ...action, + attributes: { + ...action.attributes, + config: { + apiUrl: 'https://example.com', + isLegacy: true, + }, + }, + }); + }); + + test('it does not set isLegacy config for other connectors', () => { + const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const action = getMockData(); + const migratedAction = migration716(action, context); + expect(migratedAction).toEqual(action); + }); }); describe('8.0.0', () => { @@ -306,3 +347,19 @@ function getMockData( type: 'action', }; } + +function getMockDataForServiceNow( + overwrites: Record = {} +): SavedObjectUnsanitizedDoc> { + return { + attributes: { + name: 'abc', + actionTypeId: '.servicenow', + config: { apiUrl: 'https://example.com' }, + secrets: { user: 'test', password: '123' }, + ...overwrites, + }, + id: uuid.v4(), + type: 'action', + }; +} diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index 987b3f8f0677e..688839eb89858 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -65,7 +65,9 @@ export function getActionsMigrations( const migrationActionsSixteen = createEsoMigration( encryptedSavedObjects, (doc): doc is SavedObjectUnsanitizedDoc => - doc.attributes.actionTypeId === '.servicenow' || doc.attributes.actionTypeId === '.email', + doc.attributes.actionTypeId === '.servicenow' || + doc.attributes.actionTypeId === '.servicenow-sir' || + doc.attributes.actionTypeId === '.email', pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet) ); @@ -198,7 +200,10 @@ const addIsMissingSecretsField = ( const markOldServiceNowITSMConnectorAsLegacy = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { - if (doc.attributes.actionTypeId !== '.servicenow') { + if ( + doc.attributes.actionTypeId !== '.servicenow' && + doc.attributes.actionTypeId !== '.servicenow-sir' + ) { return doc; } From b7f5bd2754b79b75de82a8dc3bcc05961be0afcb Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 4 Oct 2021 17:06:08 +0300 Subject: [PATCH 85/92] Add tests for connectors and improve callouts --- .../configure_cases/connectors.test.tsx | 15 +++++++ .../components/configure_cases/connectors.tsx | 5 ++- .../connectors_dropdown.test.tsx | 15 ++++++- .../configure_cases/connectors_dropdown.tsx | 8 ++-- .../configure_cases/deprecated_callout.tsx | 34 --------------- .../connectors/deprecated_callout.test.tsx | 32 ++++++++++++++ .../connectors/deprecated_callout.tsx | 42 +++++++++++++++++++ .../servicenow/deprecated_callout.tsx | 31 -------------- .../servicenow_itsm_case_fields.tsx | 4 +- .../servicenow/servicenow_sir_case_fields.tsx | 4 +- .../connectors/servicenow/translations.ts | 15 ------- .../plugins/cases/public/components/utils.ts | 22 ++++++++++ .../cases/public/containers/configure/mock.ts | 10 +++++ .../servicenow/helpers.ts | 6 ++- .../servicenow/servicenow_connectors.tsx | 4 +- .../servicenow/servicenow_itsm_params.tsx | 6 +-- .../servicenow/servicenow_sir_params.tsx | 6 +-- 17 files changed, 155 insertions(+), 104 deletions(-) delete mode 100644 x-pack/plugins/cases/public/components/configure_cases/deprecated_callout.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx create mode 100644 x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx delete mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index 0bda6fe185093..f27c1638cf7f2 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { Connectors, Props } from './connectors'; import { TestProviders } from '../../common/mock'; @@ -121,4 +122,18 @@ describe('Connectors', () => { .text() ).toBe('Update My Connector'); }); + + test('it shows the deprecated callout when the connector is legacy', () => { + render( + , + { + // wrapper: TestProviders produces a TS error + wrapper: ({ children }) => {children}, + } + ); + expect(screen.getByText('This connector is deprecated')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index e9357147fbd72..846b7a16b3ca6 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -22,7 +22,8 @@ import * as i18n from './translations'; import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types'; import { Mapping } from './mapping'; import { ActionTypeConnector, ConnectorTypes } from '../../../common'; -import { DeprecatedCallout } from './deprecated_callout'; +import { DeprecatedCallout } from '../connectors/deprecated_callout'; +import { isLegacyConnector } from '../utils'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -110,7 +111,7 @@ const ConnectorsComponent: React.FC = ({ appendAddConnectorButton={true} /> - {connector?.config.isLegacy && ( + {isLegacyConnector(connector) && ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 4e6ee742f442d..584ffd42184fd 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { EuiSuperSelect } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; import { ConnectorsDropdown, Props } from './connectors_dropdown'; import { TestProviders } from '../../common/mock'; @@ -29,11 +30,12 @@ describe('ConnectorsDropdown', () => { }; const { createMockActionTypeModel } = actionTypeRegistryMock; + const uniqueActionTypeIds = new Set(connectors.map((connector) => connector.actionTypeId)); beforeAll(() => { - connectors.forEach((connector) => + uniqueActionTypeIds.forEach((actionTypeId) => useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' }) ) ); wrapper = mount(, { wrappingComponent: TestProviders }); @@ -253,4 +255,13 @@ describe('ConnectorsDropdown', () => { ) ).not.toThrowError(); }); + + test('it shows the deprecated tooltip when the connector is legacy', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + const tooltips = screen.getAllByLabelText('Deprecated connector'); + expect(tooltips[0]).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index cbc44955466a5..f21b3ab3d544f 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -13,9 +13,7 @@ import { ConnectorTypes } from '../../../common'; import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; -import { getConnectorIcon } from '../utils'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ENABLE_NEW_SN_ITSM_CONNECTOR } from '../../../../actions/server/constants/connectors'; +import { getConnectorIcon, isLegacyConnector } from '../utils'; export interface Props { connectors: ActionConnector[]; @@ -91,10 +89,10 @@ const ConnectorsDropdownComponent: React.FC = ({ {connector.name} - {ENABLE_NEW_SN_ITSM_CONNECTOR && connector.config.isLegacy && ( + {isLegacyConnector(connector) && ( { - return ( - <> - -

- {i18n.translate('xpack.cases.configureCases..deprecatedCalloutMigrate', { - defaultMessage: 'Please update your connector', - })} -

-
- - ); -}; - -export const DeprecatedCallout = memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx new file mode 100644 index 0000000000000..6b1475e3c4bd0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DeprecatedCallout } from './deprecated_callout'; + +describe('DeprecatedCallout', () => { + test('it renders correctly', () => { + render(); + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect( + screen.getByText( + 'This connector type is deprecated. Create a new connector or update this connector' + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( + 'euiCallOut euiCallOut--warning' + ); + }); + + test('it renders a danger flyout correctly', () => { + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( + 'euiCallOut euiCallOut--danger' + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx new file mode 100644 index 0000000000000..937f8406e218a --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCallOut, EuiCallOutProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', + { + defaultMessage: 'Deprecated connector type', + } +); + +const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( + 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', + { + defaultMessage: + 'This connector type is deprecated. Create a new connector or update this connector', + } +); + +interface Props { + type?: EuiCallOutProps['color']; +} + +const DeprecatedCalloutComponent: React.FC = ({ type = 'warning' }) => ( + + {LEGACY_CONNECTOR_WARNING_DESC} + +); + +export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx deleted file mode 100644 index 66aa0d1ff7c74..0000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/deprecated_callout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiCallOut } from '@elastic/eui'; - -import * as i18n from './translations'; - -interface Props { - isEdit: boolean; -} - -const DeprecatedCalloutComponent: React.FC = ({ isEdit }) => { - const color = isEdit ? 'danger' : 'warning'; - return ( - - {i18n.LEGACY_CONNECTOR_WARNING_DESC} - - ); -}; - -export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 1dc32cd1d3c84..096e450c736c1 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -17,7 +17,7 @@ import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; import { connectorValidator } from './validator'; -import { DeprecatedCallout } from './deprecated_callout'; +import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -157,7 +157,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< {showConnectorWarning && ( - + )} diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 470b429d1cb3e..a7b8aa7b27df5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -18,7 +18,7 @@ import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; import { connectorValidator } from './validator'; -import { DeprecatedCallout } from './deprecated_callout'; +import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -173,7 +173,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< {showConnectorWarning && ( - + )} diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts index e371d272ef426..d9ed86b594ecc 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/translations.ts @@ -73,18 +73,3 @@ export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( defaultMessage: 'Yes', } ); - -export const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( - 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', - { - defaultMessage: 'Deprecated connector type', - } -); - -export const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( - 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', - { - defaultMessage: - 'This connector type is deprecated. Create a new connector or update this connector', - } -); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index e5d69a3fb3e59..ac5f4dbdd298e 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -12,6 +12,11 @@ import { StartPlugins } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; +import { + ENABLE_NEW_SN_ITSM_CONNECTOR, + ENABLE_NEW_SN_SIR_CONNECTOR, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../actions/server/constants/connectors'; export const getConnectorById = ( id: string, @@ -71,3 +76,20 @@ export const getConnectorIcon = ( return emptyResponse; }; + +// TODO: Remove when the applications are certified +export const isLegacyConnector = (connector?: CaseActionConnector) => { + if (connector == null) { + return true; + } + + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { + return true; + } + + if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') { + return true; + } + + return connector.config.isLegacy; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index 833c2cfb3aa7c..d1ae7f310a719 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -71,6 +71,16 @@ export const connectorsMock: ActionConnector[] = [ }, isPreconfigured: false, }, + { + id: 'servicenow-legacy', + actionTypeId: '.servicenow', + name: 'My Connector', + config: { + apiUrl: 'https://instance1.service-now.com', + isLegacy: true, + }, + isPreconfigured: false, + }, ]; export const actionTypesMock: ActionTypeConnector[] = [ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index 02552566843dc..ca557b31c4f4f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -26,7 +26,11 @@ export const isFieldInvalid = ( ): boolean => error !== undefined && error.length > 0 && field !== undefined; // TODO: Remove when the applications are certified -export const enableLegacyConnector = (connector: ServiceNowActionConnector) => { +export const isLegacyConnector = (connector: ServiceNowActionConnector) => { + if (connector == null) { + return true; + } + if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') { return true; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 2bddeb14f1227..1043ed019469d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -15,7 +15,7 @@ import { useKibana } from '../../../../common/lib/kibana'; import { DeprecatedCallout } from './deprecated_callout'; import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; -import { isRESTApiError, enableLegacyConnector } from './helpers'; +import { isRESTApiError, isLegacyConnector } from './helpers'; import { InstallationCallout } from './installation_callout'; import { UpdateConnectorModal } from './update_connector_modal'; import { updateActionConnector } from '../../../lib/action_connector_api'; @@ -38,7 +38,7 @@ const ServiceNowConnectorFields: React.FC Date: Mon, 4 Oct 2021 17:50:01 +0300 Subject: [PATCH 86/92] Add more tests --- x-pack/plugins/cases/common/ui/types.ts | 3 + .../public/common/mock/register_connectors.ts | 27 +++++ .../all_cases/all_cases_generic.test.tsx | 10 +- .../components/all_cases/columns.test.tsx | 10 +- .../components/all_cases/index.test.tsx | 10 +- .../configure_cases/connectors.test.tsx | 27 ++++- .../components/configure_cases/connectors.tsx | 2 +- .../connectors_dropdown.test.tsx | 48 ++++++-- .../components/connectors/card.test.tsx | 10 +- .../servicenow_itsm_case_fields.test.tsx | 13 ++- .../servicenow_sir_case_fields.test.tsx | 13 ++- .../connectors/servicenow/validator.test.ts | 37 +++++++ .../components/create/connector.test.tsx | 10 +- .../plugins/cases/public/components/types.ts | 4 +- .../servicenow/api.test.ts | 103 +++++++++++++++++- .../application_required_callout.test.tsx | 30 +++++ .../application_required_callout.tsx | 2 +- .../servicenow/sn_store_button.test.tsx | 27 +++++ 18 files changed, 328 insertions(+), 58 deletions(-) create mode 100644 x-pack/plugins/cases/public/common/mock/register_connectors.ts create mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 948b203af14a8..b4ed4f7db177e 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -16,6 +16,7 @@ import { User, UserAction, UserActionField, + ActionConnector, } from '../api'; export interface CasesUiConfigType { @@ -259,3 +260,5 @@ export interface Ecs { _index?: string; signal?: SignalEcs; } + +export type CaseActionConnector = ActionConnector; diff --git a/x-pack/plugins/cases/public/common/mock/register_connectors.ts b/x-pack/plugins/cases/public/common/mock/register_connectors.ts new file mode 100644 index 0000000000000..42e7cd4a85e40 --- /dev/null +++ b/x-pack/plugins/cases/public/common/mock/register_connectors.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { CaseActionConnector } from '../../../common'; + +const getUniqueActionTypeIds = (connectors: CaseActionConnector[]) => + new Set(connectors.map((connector) => connector.actionTypeId)); + +export const registerConnectorsToMockActionRegistry = ( + actionTypeRegistry: TriggersAndActionsUIPublicPluginStart['actionTypeRegistry'], + connectors: CaseActionConnector[] +) => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + const uniqueActionTypeIds = getUniqueActionTypeIds(connectors); + uniqueActionTypeIds.forEach((actionTypeId) => + actionTypeRegistry.register( + createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' }) + ) + ); +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx index 0e548fd53c89d..fed23564a3955 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx @@ -19,8 +19,8 @@ import { useKibana } from '../../common/lib/kibana'; import { StatusAll } from '../../containers/types'; import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common'; import { connectorsMock } from '../../containers/mock'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -59,14 +59,10 @@ jest.mock('../../common/lib/kibana', () => { }); describe('AllCasesGeneric ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 015ba877a2749..090ac0d31ed06 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -12,21 +12,17 @@ import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../containers/mock'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { connectors } from '../configure_cases/__mock__'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('ExternalServiceColumn ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); }); it('Not pushed render', () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 3fff43108772d..a387c5eae3834 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -32,8 +32,8 @@ import { useKibana } from '../../common/lib/kibana'; import { AllCasesGeneric as AllCases } from './all_cases_generic'; import { AllCasesProps } from '.'; import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); @@ -148,14 +148,10 @@ describe('AllCasesGeneric', () => { userCanCrud: true, }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index f27c1638cf7f2..38923784d862c 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -15,6 +15,7 @@ import { ConnectorsDropdown } from './connectors_dropdown'; import { connectors, actionTypes } from './__mock__'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -36,11 +37,10 @@ describe('Connectors', () => { updateConnectorDisabled: false, }; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; + beforeAll(() => { - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: 'test', - iconClass: 'logoSecurity', - }); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -123,7 +123,7 @@ describe('Connectors', () => { ).toBe('Update My Connector'); }); - test('it shows the deprecated callout when the connector is legacy', () => { + test('it shows the deprecated callout when the connector is legacy', async () => { render( { wrapper: ({ children }) => {children}, } ); - expect(screen.getByText('This connector is deprecated')).toBeInTheDocument(); + + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect( + screen.getByText( + 'This connector type is deprecated. Create a new connector or update this connector' + ) + ).toBeInTheDocument(); + }); + + test('it does not shows the deprecated callout when the connector is none', async () => { + render(, { + // wrapper: TestProviders produces a TS error + wrapper: ({ children }) => {children}, + }); + + expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index 846b7a16b3ca6..1b575e3ba9334 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -111,7 +111,7 @@ const ConnectorsComponent: React.FC = ({ appendAddConnectorButton={true} />
- {isLegacyConnector(connector) && ( + {selectedConnector.type !== ConnectorTypes.none && isLegacyConnector(connector) && ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 584ffd42184fd..34422392b7efa 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -14,7 +14,7 @@ import { ConnectorsDropdown, Props } from './connectors_dropdown'; import { TestProviders } from '../../common/mock'; import { connectors } from './__mock__'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -29,15 +29,10 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; - const { createMockActionTypeModel } = actionTypeRegistryMock; - const uniqueActionTypeIds = new Set(connectors.map((connector) => connector.actionTypeId)); + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - uniqueActionTypeIds.forEach((actionTypeId) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -175,6 +170,43 @@ describe('ConnectorsDropdown', () => { , "value": "servicenow-sir", }, + Object { + "data-test-subj": "dropdown-connector-servicenow-legacy", + "inputDisplay": + + + + + + My Connector + + + + + + , + "value": "servicenow-legacy", + }, ] `); }); diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx index b5d70a6781916..384442814ffef 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -10,22 +10,18 @@ import { mount } from 'enzyme'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { connectors } from '../configure_cases/__mock__'; import { ConnectorCard } from './card'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('ConnectorCard ', () => { - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectors.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors); }); it('it does not throw when accessing the icon if the connector type is not registered', () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index b14842bbf1bbf..008340b6b7e97 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, render, screen } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; import { mount } from 'enzyme'; @@ -127,6 +127,17 @@ describe('ServiceNowITSM Fields', () => { ); }); + test('it shows the deprecated callout when the connector is legacy', async () => { + const legacyConnector = { ...connector, config: { isLegacy: true } }; + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument(); + }); + + test('it does not show the deprecated callout when the connector is not legacy', async () => { + render(); + expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument(); + }); + describe('onChange calls', () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index 1117f75cddffc..aac78b8266fb5 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, render, screen } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; import { useKibana } from '../../../common/lib/kibana'; @@ -161,6 +161,17 @@ describe('ServiceNowSIR Fields', () => { ]); }); + test('it shows the deprecated callout when the connector is legacy', async () => { + const legacyConnector = { ...connector, config: { isLegacy: true } }; + render(); + expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument(); + }); + + test('it does not show the deprecated callout when the connector is not legacy', async () => { + render(); + expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument(); + }); + describe('onChange calls', () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts new file mode 100644 index 0000000000000..c098d803276bc --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { connector } from '../mock'; +import { connectorValidator } from './validator'; + +describe('ServiceNow validator', () => { + describe('connectorValidator', () => { + test('it returns an error message if the connector is legacy', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + isLegacy: true, + }, + }; + + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' }); + }); + + test('it does not returns an error message if the connector is not legacy', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + isLegacy: false, + }, + }; + + expect(connectorValidator(invalidConnector)).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index a2ffd42f2660b..ea7435c2cba45 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -22,8 +22,8 @@ import { TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; -import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; import { useKibana } from '../../common/lib/kibana'; +import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -86,14 +86,10 @@ describe('Connector', () => { return
{children}
; }; - const { createMockActionTypeModel } = actionTypeRegistryMock; + const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; beforeAll(() => { - connectorsMock.forEach((connector) => - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( - createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) - ) - ); + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/types.ts b/x-pack/plugins/cases/public/components/types.ts index 014afc371e761..07ab5814b082b 100644 --- a/x-pack/plugins/cases/public/components/types.ts +++ b/x-pack/plugins/cases/public/components/types.ts @@ -5,6 +5,4 @@ * 2.0. */ -import { ActionConnector } from '../../common'; - -export type CaseActionConnector = ActionConnector; +export { CaseActionConnector } from '../../common'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts index ba820efc8111f..a60bafbb043fc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -6,7 +6,7 @@ */ import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; -import { getChoices } from './api'; +import { getChoices, getAppInfo } from './api'; const choicesResponse = { status: 'ok', @@ -44,10 +44,27 @@ const choicesResponse = { ], }; +const applicationInfoData = { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, +}; + +const applicationInfoResponse = { + ok: true, + status: 200, + json: async () => applicationInfoData, +}; + describe('ServiceNow API', () => { const http = httpServiceMock.createStartContract(); + let fetchMock: jest.SpyInstance>; - beforeEach(() => jest.resetAllMocks()); + beforeAll(() => { + fetchMock = jest.spyOn(window, 'fetch'); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); describe('getChoices', () => { test('should call get choices API', async () => { @@ -67,4 +84,86 @@ describe('ServiceNow API', () => { }); }); }); + + describe('getAppInfo', () => { + test('should call getAppInfo API for ITSM', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + const res = await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }); + + expect(res).toEqual(applicationInfoData.result); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + { + signal: abortCtrl.signal, + method: 'GET', + headers: { Authorization: 'Basic dGVzdDp0ZXN0' }, + } + ); + }); + + test('should call getAppInfo API correctly for SIR', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + const res = await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow-sir', + }); + + expect(res).toEqual(applicationInfoData.result); + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + { + signal: abortCtrl.signal, + method: 'GET', + headers: { Authorization: 'Basic dGVzdDp0ZXN0' }, + } + ); + }); + + it('returns an error when the response fails', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + try { + await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }); + } catch (e) { + expect(e.message).toContain('Received status:'); + } + }); + + it('returns an error when parsing the json fails', async () => { + const abortCtrl = new AbortController(); + fetchMock.mockResolvedValueOnce(applicationInfoResponse); + + try { + await getAppInfo({ + signal: abortCtrl.signal, + apiUrl: 'https://example.com', + username: 'test', + password: 'test', + actionTypeId: '.servicenow', + }); + } catch (e) { + expect(e.message).toContain('bad'); + } + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx new file mode 100644 index 0000000000000..67c3238b04774 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ApplicationRequiredCallout } from './application_required_callout'; + +describe('ApplicationRequiredCallout', () => { + test('it renders the callout', () => { + render(); + expect(screen.getByText('Elastic ServiceNow App not installed')).toBeInTheDocument(); + expect( + screen.getByText('Please go to the ServiceNow app store and install the application') + ).toBeInTheDocument(); + }); + + test('it renders the ServiceNow store button', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + test('it renders an error message if provided', () => { + render(); + expect(screen.getByText('Error message: Denied')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 8dd7e0c8c38a9..561dae95fe1b7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -25,7 +25,7 @@ const ERROR_MESSAGE = i18n.translate( ); interface Props { - message: string | null; + message?: string | null; } const ApplicationRequiredCalloutComponent: React.FC = ({ message }) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx new file mode 100644 index 0000000000000..fe73653234170 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SNStoreButton } from './sn_store_button'; + +describe('SNStoreButton', () => { + test('it renders the button', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + test('it renders a danger button', () => { + render(); + expect(screen.getByRole('link')).toHaveClass('euiButton--danger'); + }); + + test('it renders with correct href', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); + }); +}); From 8fead6418973a1deaaa009eee5340cb841052605 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 5 Oct 2021 17:58:39 +0300 Subject: [PATCH 87/92] Add more UI tests --- .../servicenow/deprecated_callout.test.tsx | 35 +++++++ .../servicenow/helpers.test.ts | 47 +++++++++ .../servicenow/installation_callout.test.tsx | 27 +++++ .../servicenow/servicenow_connectors.tsx | 1 - .../servicenow/use_get_app_info.test.tsx | 98 +++++++++++++++++++ .../servicenow/use_get_app_info.tsx | 14 +-- 6 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx new file mode 100644 index 0000000000000..767b38ebcf6ad --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { DeprecatedCallout } from './deprecated_callout'; + +describe('DeprecatedCallout', () => { + const onMigrate = jest.fn(); + + test('it renders correctly', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + }); + + test('it calls onMigrate when pressing the button', () => { + render(, { + wrapper: ({ children }) => {children}, + }); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(onMigrate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts new file mode 100644 index 0000000000000..e37d8dd3b4147 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isRESTApiError, isFieldInvalid } from './helpers'; + +describe('helpers', () => { + describe('isRESTApiError', () => { + const resError = { error: { message: 'error', detail: 'access denied' }, status: '401' }; + + test('should return true if the error is RESTApiError', async () => { + expect(isRESTApiError(resError)).toBeTruthy(); + }); + + test('should return true if there is failure status', async () => { + // @ts-expect-error + expect(isRESTApiError({ status: 'failure' })).toBeTruthy(); + }); + + test('should return false if there is no error', async () => { + // @ts-expect-error + expect(isRESTApiError({ whatever: 'test' })).toBeFalsy(); + }); + }); + + describe('isFieldInvalid', () => { + test('should return true if the field is invalid', async () => { + expect(isFieldInvalid('description', ['required'])).toBeTruthy(); + }); + + test('should return if false the field is not defined', async () => { + expect(isFieldInvalid(undefined, ['required'])).toBeFalsy(); + }); + + test('should return if false the error is not defined', async () => { + // @ts-expect-error + expect(isFieldInvalid('description', undefined)).toBeFalsy(); + }); + + test('should return if false the error is empty', async () => { + expect(isFieldInvalid('description', [])).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx new file mode 100644 index 0000000000000..8e1c1820920c5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { InstallationCallout } from './installation_callout'; + +describe('DeprecatedCallout', () => { + test('it renders correctly', () => { + render(); + expect( + screen.getByText( + 'To use this connector, you must first install the Elastic App from the ServiceNow App Store' + ) + ).toBeInTheDocument(); + }); + + test('it renders the button', () => { + render(); + expect(screen.getByRole('link')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 1043ed019469d..cada62017a500 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -43,7 +43,6 @@ const ServiceNowConnectorFields: React.FC { + getAppInfoMock.mockResolvedValue(applicationInfoData); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + fetchAppInfo: result.current.fetchAppInfo, + }); + }); + + it('returns the application information', async () => { + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + let res; + + await act(async () => { + res = await result.current.fetchAppInfo(actionConnector); + }); + + expect(res).toEqual(applicationInfoData); + }); + + it('it throws an error when api fails', async () => { + getAppInfoMock.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + const { result } = renderHook(() => + useGetAppInfo({ + actionTypeId, + }) + ); + + try { + await act(async () => { + await result.current.fetchAppInfo(actionConnector); + }); + + fail('Should never get here'); + } catch (e) { + expect(e.message).toBe('An error occurred'); + } + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx index 1f75d81f6478d..a211c8dda66b7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.tsx @@ -6,27 +6,19 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; -import { ToastsApi } from 'kibana/public'; import { getAppInfo } from './api'; import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; -export interface UseGetChoicesProps { +export interface UseGetAppInfoProps { actionTypeId: string; - toastNotifications: Pick< - ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' - >; } -export interface UseGetChoices { +export interface UseGetAppInfo { fetchAppInfo: (connector: ServiceNowActionConnector) => Promise; isLoading: boolean; } -export const useGetAppInfo = ({ - actionTypeId, - toastNotifications, -}: UseGetChoicesProps): UseGetChoices => { +export const useGetAppInfo = ({ actionTypeId }: UseGetAppInfoProps): UseGetAppInfo => { const [isLoading, setIsLoading] = useState(false); const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); From 6d755460bc969e63a708e57707bf005f6abdfcda Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 7 Oct 2021 12:00:00 +0300 Subject: [PATCH 88/92] PR feedback --- .../servicenow/api.test.ts | 20 ++-- .../servicenow/api_sir.test.ts | 39 ++++---- .../builtin_action_types/servicenow/mocks.ts | 25 ++--- .../servicenow/service.test.ts | 92 +++++++++---------- .../servicenow/service_sir.test.ts | 8 +- .../servicenow/utils.test.ts | 4 +- .../builtin_action_types/servicenow/utils.ts | 6 +- .../builtin_action_types/servicenow.ts | 16 ++-- 8 files changed, 101 insertions(+), 109 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 7076a3e8f1471..e1f66263729e2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -97,7 +97,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', opened_by: 'elastic', }, }); @@ -126,7 +126,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -142,7 +142,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -170,7 +170,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -186,7 +186,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-1', }); @@ -263,7 +263,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -290,7 +290,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -306,7 +306,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); @@ -333,7 +333,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-3', }); @@ -349,7 +349,7 @@ describe('api', () => { description: 'Incident description', short_description: 'Incident title', correlation_display: 'Alerting', - correlation_id: 'alertID', + correlation_id: 'ruleId', }, incidentId: 'incident-2', }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts index 0ca3dce65cbd9..358af7cd2e9ef 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -97,11 +97,19 @@ describe('api_sir', () => { describe('formatObservables', () => { test('it formats array observables correctly', async () => { - expect(formatObservables(['a', 'b', 'c'], ObservableTypes.ip4)).toEqual([ - { type: 'ipv4-addr', value: 'a' }, - { type: 'ipv4-addr', value: 'b' }, - { type: 'ipv4-addr', value: 'c' }, - ]); + const expectedTypes: Array<[ObservableTypes, string]> = [ + [ObservableTypes.ip4, 'ipv4-addr'], + [ObservableTypes.sha256, 'SHA256'], + [ObservableTypes.url, 'URL'], + ]; + + for (const type of expectedTypes) { + expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([ + { type: type[1], value: 'a' }, + { type: type[1], value: 'b' }, + { type: type[1], value: 'c' }, + ]); + } }); test('it removes duplicates from array observables correctly', async () => { @@ -151,20 +159,19 @@ describe('api_sir', () => { }); test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => { + const { + dest_ip: destIp, + source_ip: sourceIp, + malware_hash: malwareHash, + malware_url: malwareURL, + ...incidentWithoutObservables + } = sirParams.incident; + expect( prepareParams(true, { ...sirParams, - incident: { - ...sirParams.incident, - // @ts-expect-error - dest_ip: undefined, - // @ts-expect-error - source_ip: undefined, - // @ts-expect-error - malware_hash: undefined, - // @ts-expect-error - malware_url: undefined, - }, + // @ts-expect-error + incident: incidentWithoutObservables, }) ).toEqual({ ...sirParams, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts index 1d4734ae53576..3629fb33915ae 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -151,15 +151,15 @@ const createSIRMock = (): jest.Mocked => { return service; }; -const externalServiceMock = { +export const externalServiceMock = { create: createMock, }; -const externalServiceSIRMock = { +export const externalServiceSIRMock = { create: createSIRMock, }; -const executorParams: ExecutorSubActionPushParams = { +export const executorParams: ExecutorSubActionPushParams = { incident: { externalId: 'incident-3', short_description: 'Incident title', @@ -169,7 +169,7 @@ const executorParams: ExecutorSubActionPushParams = { impact: '3', category: 'software', subcategory: 'os', - correlation_id: 'alertID', + correlation_id: 'ruleId', correlation_display: 'Alerting', }, comments: [ @@ -184,7 +184,7 @@ const executorParams: ExecutorSubActionPushParams = { ], }; -const sirParams: PushToServiceApiParamsSIR = { +export const sirParams: PushToServiceApiParamsSIR = { incident: { externalId: 'incident-3', short_description: 'Incident title', @@ -195,7 +195,7 @@ const sirParams: PushToServiceApiParamsSIR = { malware_url: ['https://example.com'], category: 'software', subcategory: 'os', - correlation_id: 'alertID', + correlation_id: 'ruleId', correlation_display: 'Alerting', priority: '1', }, @@ -211,7 +211,7 @@ const sirParams: PushToServiceApiParamsSIR = { ], }; -const observables: Observable[] = [ +export const observables: Observable[] = [ { value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9', type: ObservableTypes.sha256, @@ -226,13 +226,4 @@ const observables: Observable[] = [ }, ]; -const apiParams = executorParams; - -export { - externalServiceMock, - executorParams, - apiParams, - sirParams, - externalServiceSIRMock, - observables, -}; +export const apiParams = executorParams; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 825b45f3a8e99..b8499b01e6a02 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -40,7 +40,7 @@ const getImportSetAPIResponse = (update = false) => ({ table: 'incident', display_name: 'number', display_value: 'INC01', - record_link: 'https://dev102283.service-now.com/api/now/table/incident/1', + record_link: 'https://example.com/api/now/table/incident/1', status: update ? 'updated' : 'inserted', sys_id: '1', }, @@ -117,7 +117,7 @@ const expectImportedIncident = (update: boolean) => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/x_elas2_inc_int/elastic_api/health', + url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', method: 'get', }); @@ -125,7 +125,7 @@ const expectImportedIncident = (update: boolean) => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/import/x_elas2_inc_int_elastic_incident', + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', method: 'post', data: { u_short_description: 'title', @@ -138,7 +138,7 @@ const expectImportedIncident = (update: boolean) => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', }); }; @@ -151,7 +151,7 @@ describe('ServiceNow service', () => { { // The trailing slash at the end of the url is intended. // All API calls need to have the trailing slash removed. - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -227,7 +227,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + url: 'https://example.com/api/now/v2/table/incident/1', method: 'get', }); }); @@ -235,7 +235,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -252,7 +252,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', }); }); @@ -287,7 +287,7 @@ describe('ServiceNow service', () => { title: 'INC01', id: '1', pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', }); }); @@ -300,7 +300,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -314,7 +314,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/health', + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', }); @@ -322,7 +322,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc' }, }); @@ -331,13 +331,11 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); test('it should throw an error when the application is not installed', async () => { @@ -350,7 +348,7 @@ describe('ServiceNow service', () => { incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }) ).rejects.toThrow( - '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' ); }); @@ -386,7 +384,7 @@ describe('ServiceNow service', () => { beforeEach(() => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -405,7 +403,7 @@ describe('ServiceNow service', () => { title: 'INC01', id: '1', pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', }); expect(requestMock).toHaveBeenCalledTimes(2); @@ -413,7 +411,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident', + url: 'https://example.com/api/now/v2/table/incident', method: 'post', data: { short_description: 'title', description: 'desc' }, }); @@ -422,7 +420,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -440,14 +438,12 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident', + url: 'https://example.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' - ); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); }); }); @@ -462,7 +458,7 @@ describe('ServiceNow service', () => { title: 'INC01', id: '1', pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', }); }); @@ -474,7 +470,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -487,7 +483,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/health', + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', }); @@ -495,7 +491,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', method: 'post', data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, }); @@ -504,13 +500,11 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'get', }); - expect(res.url).toEqual( - 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1' - ); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); test('it should throw an error when the application is not installed', async () => { @@ -524,7 +518,7 @@ describe('ServiceNow service', () => { incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, }) ).rejects.toThrow( - '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown Reason: unknown' + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' ); }); @@ -562,7 +556,7 @@ describe('ServiceNow service', () => { beforeEach(() => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -582,7 +576,7 @@ describe('ServiceNow service', () => { title: 'INC01', id: '1', pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', }); expect(requestMock).toHaveBeenCalledTimes(2); @@ -590,7 +584,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + url: 'https://example.com/api/now/v2/table/incident/1', method: 'patch', data: { short_description: 'title', description: 'desc' }, }); @@ -599,7 +593,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -618,14 +612,12 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1', + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', method: 'patch', 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' - ); + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); }); }); @@ -641,7 +633,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -656,7 +648,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -673,7 +665,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); }); @@ -709,7 +701,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -724,7 +716,7 @@ describe('ServiceNow service', () => { test('it should call request with correct arguments when table changes', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, @@ -742,7 +734,7 @@ describe('ServiceNow service', () => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element', }); }); @@ -769,7 +761,7 @@ describe('ServiceNow service', () => { describe('getUrl', () => { test('it returns the instance url', async () => { - expect(service.getUrl()).toBe('https://dev102283.service-now.com'); + expect(service.getUrl()).toBe('https://example.com'); }); }); @@ -828,7 +820,7 @@ describe('ServiceNow service', () => { test('it does not log if useOldApi = true', async () => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts index c47fb506013d9..0fc94b6287abd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts @@ -68,13 +68,13 @@ const expectAddObservables = (single: boolean) => { axios, logger, configurationUtilities, - url: 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/health', + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', method: 'get', }); const url = single - ? 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables' - : 'https://dev102283.service-now.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk'; + ? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables' + : 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk'; const data = single ? observables[0] : observables; @@ -94,7 +94,7 @@ describe('ServiceNow SIR service', () => { beforeEach(() => { service = createExternalServiceSIR( { - config: { apiUrl: 'https://dev102283.service-now.com/' }, + config: { apiUrl: 'https://example.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index ce44200191ab6..87f27da6d213f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -36,7 +36,7 @@ describe('utils', () => { const error = new Error('An error occurred'); // @ts-expect-error expect(createServiceError(error, 'Unable to do action').message).toBe( - '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown' + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null' ); }); @@ -58,7 +58,7 @@ describe('utils', () => { } as AxiosError; expect(createServiceError(axiosError, 'Unable to do action').message).toBe( - '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown' + '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response' ); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 66341cfa4326c..5b7ca99ffc709 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -20,11 +20,13 @@ export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): const createErrorMessage = (errorResponse?: ServiceNowError): string => { if (errorResponse == null) { - return 'unknown'; + return 'unknown: errorResponse was null'; } const { error } = errorResponse; - return error != null ? `${error?.message}: ${error?.detail}` : 'unknown'; + return error != null + ? `${error?.message}: ${error?.detail}` + : 'unknown: no error in error response'; }; export const createServiceError = (error: ResponseError, message: string) => diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 7412138750817..15fae70efe0ea 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -57,7 +57,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, @@ -69,7 +69,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { expect(createdAction).to.eql({ id: createdAction.id, is_preconfigured: false, - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, is_missing_secrets: false, config: { @@ -85,7 +85,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { expect(fetchedAction).to.eql({ id: fetchedAction.id, is_preconfigured: false, - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, is_missing_secrets: false, config: { @@ -100,7 +100,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, @@ -121,7 +121,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, config: {}, }) @@ -141,7 +141,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, config: { apiUrl: 'http://servicenow.mynonexistent.com', @@ -164,7 +164,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow action', + name: `A connector with type ${connectorType}`, connector_type_id: connectorType, config: { apiUrl: serviceNowSimulatorURL, @@ -569,7 +569,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { malware_url: ['https://example.com'], category: 'software', subcategory: 'os', - correlation_id: 'alertID', + correlation_id: 'ruleId', correlation_display: 'Alerting', priority: '1', }, From d2b1443f92d0b3daf7d0e1083314323e46f2733e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 7 Oct 2021 12:51:21 +0300 Subject: [PATCH 89/92] Test CI --- .../tests/actions/builtin_action_types/servicenow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 15fae70efe0ea..16a6db3a1b5d2 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -19,7 +19,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - describe('ServiceNow', () => { + describe.skip('ServiceNow', () => { let serviceNowSimulatorURL: string = ''; let serviceNowServer: http.Server; let proxyServer: httpProxy | undefined; From 44cdf201586cf1b8163f0d0799cbf64dddbb06c0 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 7 Oct 2021 14:51:23 +0300 Subject: [PATCH 90/92] Improve integration tests --- .../builtin_action_types/servicenow.ts | 748 +++++++++--------- 1 file changed, 377 insertions(+), 371 deletions(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 16a6db3a1b5d2..852c42f5eb876 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -19,7 +19,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - describe.skip('ServiceNow', () => { + describe('ServiceNow', () => { let serviceNowSimulatorURL: string = ''; let serviceNowServer: http.Server; let proxyServer: httpProxy | undefined; @@ -52,133 +52,135 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { connectorWithParams: Record, connectorType: string ) => { - it('should return 200 when creating a servicenow action successfully', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ + describe(`ServiceNow: Create connector: Connector Type: ${connectorType}`, () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `A connector with type ${connectorType}`, + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: connectorWithParams.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, name: `A connector with type ${connectorType}`, connector_type_id: connectorType, + is_missing_secrets: false, config: { apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, - secrets: connectorWithParams.secrets, - }) - .expect(200); - - expect(createdAction).to.eql({ - id: createdAction.id, - is_preconfigured: false, - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - is_missing_secrets: false, - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: false, - }, - }); + }); - const { body: fetchedAction } = await supertest - .get(`/api/actions/connector/${createdAction.id}`) - .expect(200); - - expect(fetchedAction).to.eql({ - id: fetchedAction.id, - is_preconfigured: false, - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - is_missing_secrets: false, - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: false, - }, - }); - }); + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); - it('should set the isLegacy to false when not provided', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, name: `A connector with type ${connectorType}`, connector_type_id: connectorType, + is_missing_secrets: false, config: { apiUrl: serviceNowSimulatorURL, + isLegacy: false, }, - secrets: connectorWithParams.secrets, - }) - .expect(200); + }); + }); + + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `A connector with type ${connectorType}`, + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: connectorWithParams.secrets, + }) + .expect(200); - const { body: fetchedAction } = await supertest - .get(`/api/actions/connector/${createdAction.id}`) - .expect(200); + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); - expect(fetchedAction.config.isLegacy).to.be(false); - }); + expect(fetchedAction.config.isLegacy).to.be(false); + }); - it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { - await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: {}, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `A connector with type ${connectorType}`, + connector_type_id: connectorType, + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); }); - }); - }); + }); - it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { - await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: { - apiUrl: 'http://servicenow.mynonexistent.com', - }, - secrets: connectorWithParams.secrets, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `A connector with type ${connectorType}`, + connector_type_id: connectorType, + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: connectorWithParams.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); }); - }); - }); + }); - it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { - await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - }, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `A connector with type ${connectorType}`, + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + }); }); - }); + }); }); }; @@ -188,231 +190,185 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { ) => { let connectorId: string = ''; - before(async () => { - const { body } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow simulator', - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: false, - }, - secrets: connectorWithParams.secrets, - }); - - connectorId = body.id; - }); + describe(`ServiceNow: Execute Validation: Connector Type: ${connectorType}`, () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + secrets: connectorWithParams.secrets, + }); - it('should handle failing with a simulated success without action', async () => { - await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: {}, - }) - .then((resp: any) => { - expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); - expect(resp.body.connector_id).to.eql(connectorId); - expect(resp.body.status).to.eql('error'); - expect(resp.body.retry).to.eql(false); - // Node.js 12 oddity: - // - // The first time after the server is booted, the error message will be: - // - // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) - // - // After this, the error will be: - // - // Cannot destructure property 'value' of 'undefined' as it is undefined. - // - // The error seems to come from the exact same place in the code based on the - // exact same circumstances: - // - // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 - // - // What triggers the error is that the `handleError` function expects its 2nd - // argument to be an object containing a `valids` property of type array. - // - // In this test the object does not contain a `valids` property, so hence the - // error. - // - // Why the error message isn't the same in all scenarios is unknown to me and - // could be a bug in V8. - expect(resp.body.message).to.match( - /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ - ); - }); - }); + connectorId = body.id; + }); - it('should handle failing with a simulated success without unsupported action', async () => { - await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'non-supported' }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: connectorId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(connectorId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circumstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); }); - }); - }); + }); - it('should handle failing with a simulated success without subActionParams', async () => { - await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { subAction: 'pushToService' }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: connectorId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', + }); }); - }); - }); + }); - it('should handle failing with a simulated success without title', async () => { - await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...connectorWithParams.params, - subActionParams: { - savedObjectId: 'success', - }, - }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: connectorId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); }); - }); - }); + }); - it('should handle failing with a simulated success without commentId', async () => { - await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...connectorWithParams.params, - subActionParams: { - incident: { - ...connectorWithParams.params.subActionParams.incident, - short_description: 'success', + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + savedObjectId: 'success', }, - comments: [{ comment: 'boo' }], }, - }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: connectorId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); }); - }); - }); + }); - it('should handle failing with a simulated success without comment message', async () => { - await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...connectorWithParams.params, - subActionParams: { - incident: { - ...connectorWithParams.params.subActionParams.incident, - short_description: 'success', + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + incident: { + ...connectorWithParams.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ comment: 'boo' }], }, - comments: [{ commentId: 'success' }], }, - }, - }) - .then((resp: any) => { - expect(resp.body).to.eql({ - connector_id: connectorId, - status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); }); - }); - }); - }; - - const testExecute = (connectorWithParams: Record, connectorType: string) => { - const tableName = connectorType === '.servicenow-sir' ? 'sn_si_incident' : 'incident'; - let connectorId: string = ''; - - before(async () => { - const { body } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow simulator', - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: true, - }, - secrets: connectorWithParams.secrets, - }); - - connectorId = body.id; - }); + }); - describe('Import set API', () => { - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest + it('should handle failing with a simulated success without comment message', async () => { + await supertest .post(`/api/actions/connector/${connectorId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { ...connectorWithParams.params, subActionParams: { - incident: connectorWithParams.params.subActionParams.incident, - comments: [], + incident: { + ...connectorWithParams.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ commentId: 'success' }], }, }, }) - .expect(200); - - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: connectorId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, - }, - }); + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: connectorId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); }); }); + }; - // Legacy connectors - describe('Table API', () => { + const testExecute = (connectorWithParams: Record, connectorType: string) => { + const tableName = connectorType === '.servicenow-sir' ? 'sn_si_incident' : 'incident'; + let connectorId: string = ''; + + describe(`ServiceNow: Execute: Connector Type: ${connectorType}`, () => { before(async () => { const { body } = await supertest .post('/api/actions/connector') @@ -430,79 +386,129 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { connectorId = body.id; }); - it('should handle creating an incident without comments', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - ...connectorWithParams.params, - subActionParams: { - incident: connectorWithParams.params.subActionParams.incident, - comments: [], + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + incident: connectorWithParams.params.subActionParams.incident, + comments: [], + }, }, - }, - }) - .expect(200); + }) + .expect(200); - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: connectorId, - data: { - id: '123', - title: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, - }, + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: connectorId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, + }, + }); }); }); - }); - describe('getChoices', () => { - it('should get choices', async () => { - const { body: result } = await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - subAction: 'getChoices', - subActionParams: { fields: ['priority'] }, - }, - }) - .expect(200); + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: connectorType, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: connectorWithParams.secrets, + }); - expect(proxyHaveBeenCalled).to.equal(true); - expect(result).to.eql({ - status: 'ok', - connector_id: connectorId, - 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', + connectorId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...connectorWithParams.params, + subActionParams: { + incident: connectorWithParams.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: connectorId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, }, - ], + }); + }); + }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: connectorId, + 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', + }, + ], + }); }); }); }); From 766dcf6b697db111eba9f292d445008f47c301e9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 11 Oct 2021 15:27:20 +0300 Subject: [PATCH 91/92] Seperate ServiceNow integration tests --- .../server/servicenow_simulation.ts | 2 +- .../{servicenow.ts => servicenow_itsm.ts} | 499 +++++++--------- .../builtin_action_types/servicenow_sir.ts | 544 ++++++++++++++++++ .../tests/actions/index.ts | 3 +- 4 files changed, 768 insertions(+), 280 deletions(-) rename x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/{servicenow.ts => servicenow_itsm.ts} (53%) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts index 20b7f754463bb..19a789659fd7f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/servicenow_simulation.ts @@ -42,7 +42,7 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo // Import Set API: Create or update incident if ( pathName.includes('x_elas2_inc_int_elastic_incident') || - pathName.includes('x_elas2_inc_int_elastic_incident') + pathName.includes('x_elas2_sir_int_elastic_si_incident') ) { const update = data?.elastic_incident_id != null; return sendResponse(response, { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts similarity index 53% rename from x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts index 852c42f5eb876..fe1ebdf8d28a9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_itsm.ts @@ -15,11 +15,44 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export -export default function serviceNowTest({ getService }: FtrProviderContext) { +export default function serviceNowITSMTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - describe('ServiceNow', () => { + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + description: 'a description', + externalId: null, + impact: '1', + severity: '1', + short_description: 'a title', + urgency: '1', + category: 'software', + subcategory: 'os', + }, + comments: [ + { + comment: 'first comment', + commentId: '456', + }, + ], + }, + }, + }; + + describe('ServiceNow ITSM', () => { + let simulatedActionId = ''; let serviceNowSimulatorURL: string = ''; let serviceNowServer: http.Server; let proxyServer: httpProxy | undefined; @@ -48,176 +81,165 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { } }); - const testConnectorCreation = ( - connectorWithParams: Record, - connectorType: string - ) => { - describe(`ServiceNow: Create connector: Connector Type: ${connectorType}`, () => { - it('should return 200 when creating a servicenow action successfully', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - }, - secrets: connectorWithParams.secrets, - }) - .expect(200); - - expect(createdAction).to.eql({ - id: createdAction.id, - is_preconfigured: false, - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - is_missing_secrets: false, + describe('ServiceNow ITSM - Action Creation', () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', config: { apiUrl: serviceNowSimulatorURL, - isLegacy: false, }, - }); + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); - const { body: fetchedAction } = await supertest - .get(`/api/actions/connector/${createdAction.id}`) - .expect(200); + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + }); - expect(fetchedAction).to.eql({ - id: fetchedAction.id, - is_preconfigured: false, - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - is_missing_secrets: false, + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', config: { apiUrl: serviceNowSimulatorURL, - isLegacy: false, }, - }); - }); - - it('should set the isLegacy to false when not provided', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - }, - secrets: connectorWithParams.secrets, - }) - .expect(200); + secrets: mockServiceNow.secrets, + }) + .expect(200); - const { body: fetchedAction } = await supertest - .get(`/api/actions/connector/${createdAction.id}`) - .expect(200); + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); - expect(fetchedAction.config.isLegacy).to.be(false); - }); + expect(fetchedAction.config.isLegacy).to.be(false); + }); - it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { - await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: {}, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', - }); + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', }); - }); + }); + }); - it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { - await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: { - apiUrl: 'http://servicenow.mynonexistent.com', - }, - secrets: connectorWithParams.secrets, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', - }); + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: mockServiceNow.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', }); - }); + }); + }); - it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { - await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: `A connector with type ${connectorType}`, - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - }, - }) - .expect(400) - .then((resp: any) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: - 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', - }); + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', }); - }); + }); }); - }; - - const testExecuteValidation = ( - connectorWithParams: Record, - connectorType: string - ) => { - let connectorId: string = ''; - - describe(`ServiceNow: Execute Validation: Connector Type: ${connectorType}`, () => { - before(async () => { - const { body } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow simulator', - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: false, - }, - secrets: connectorWithParams.secrets, - }); + }); - connectorId = body.id; - }); + describe('ServiceNow ITSM - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + describe('Validation', () => { it('should handle failing with a simulated success without action', async () => { await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: {}, }) .then((resp: any) => { expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); - expect(resp.body.connector_id).to.eql(connectorId); + expect(resp.body.connector_id).to.eql(simulatedActionId); expect(resp.body.status).to.eql('error'); expect(resp.body.retry).to.eql(false); // Node.js 12 oddity: @@ -251,14 +273,14 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without unsupported action', async () => { await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { subAction: 'non-supported' }, }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: connectorId, + connector_id: simulatedActionId, status: 'error', retry: false, message: @@ -269,14 +291,14 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without subActionParams', async () => { await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { subAction: 'pushToService' }, }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: connectorId, + connector_id: simulatedActionId, status: 'error', retry: false, message: @@ -287,11 +309,11 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without title', async () => { await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...connectorWithParams.params, + ...mockServiceNow.params, subActionParams: { savedObjectId: 'success', }, @@ -299,7 +321,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: connectorId, + connector_id: simulatedActionId, status: 'error', retry: false, message: @@ -310,14 +332,14 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without commentId', async () => { await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...connectorWithParams.params, + ...mockServiceNow.params, subActionParams: { incident: { - ...connectorWithParams.params.subActionParams.incident, + ...mockServiceNow.params.subActionParams.incident, short_description: 'success', }, comments: [{ comment: 'boo' }], @@ -326,7 +348,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: connectorId, + connector_id: simulatedActionId, status: 'error', retry: false, message: @@ -337,14 +359,14 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without comment message', async () => { await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...connectorWithParams.params, + ...mockServiceNow.params, subActionParams: { incident: { - ...connectorWithParams.params.subActionParams.incident, + ...mockServiceNow.params.subActionParams.incident, short_description: 'success', }, comments: [{ commentId: 'success' }], @@ -353,7 +375,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: connectorId, + connector_id: simulatedActionId, status: 'error', retry: false, message: @@ -361,41 +383,43 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { }); }); }); - }); - }; - const testExecute = (connectorWithParams: Record, connectorType: string) => { - const tableName = connectorType === '.servicenow-sir' ? 'sn_si_incident' : 'incident'; - let connectorId: string = ''; - - describe(`ServiceNow: Execute: Connector Type: ${connectorType}`, () => { - before(async () => { - const { body } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A servicenow simulator', - connector_type_id: connectorType, - config: { - apiUrl: serviceNowSimulatorURL, - isLegacy: true, - }, - secrets: connectorWithParams.secrets, - }); - - connectorId = body.id; + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); }); + }); + describe('Execution', () => { + // New connectors describe('Import set API', () => { it('should handle creating an incident without comments', async () => { const { body: result } = await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...connectorWithParams.params, + ...mockServiceNow.params, subActionParams: { - incident: connectorWithParams.params.subActionParams.incident, + incident: mockServiceNow.params.subActionParams.incident, comments: [], }, }, @@ -405,12 +429,12 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { expect(proxyHaveBeenCalled).to.equal(true); expect(result).to.eql({ status: 'ok', - connector_id: connectorId, + connector_id: simulatedActionId, data: { id: '123', title: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', - url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, }); }); @@ -424,26 +448,25 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ name: 'A servicenow simulator', - connector_type_id: connectorType, + connector_type_id: '.servicenow', config: { apiUrl: serviceNowSimulatorURL, isLegacy: true, }, - secrets: connectorWithParams.secrets, + secrets: mockServiceNow.secrets, }); - - connectorId = body.id; + simulatedActionId = body.id; }); it('should handle creating an incident without comments', async () => { const { body: result } = await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...connectorWithParams.params, + ...mockServiceNow.params, subActionParams: { - incident: connectorWithParams.params.subActionParams.incident, + incident: mockServiceNow.params.subActionParams.incident, comments: [], }, }, @@ -453,12 +476,12 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { expect(proxyHaveBeenCalled).to.equal(true); expect(result).to.eql({ status: 'ok', - connector_id: connectorId, + connector_id: simulatedActionId, data: { id: '123', title: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', - url: `${serviceNowSimulatorURL}/nav_to.do?uri=${tableName}.do?sys_id=123`, + url: `${serviceNowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, }, }); }); @@ -467,7 +490,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { describe('getChoices', () => { it('should get choices', async () => { const { body: result } = await supertest - .post(`/api/actions/connector/${connectorId}/_execute`) + .post(`/api/actions/connector/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -480,7 +503,7 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { expect(proxyHaveBeenCalled).to.equal(true); expect(result).to.eql({ status: 'ok', - connector_id: connectorId, + connector_id: simulatedActionId, data: [ { dependent_value: '', @@ -512,86 +535,6 @@ export default function serviceNowTest({ getService }: FtrProviderContext) { }); }); }); - }; - - describe('ServiceNow ITSM', () => { - const connectorWithParams = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - isLegacy: false, - }, - secrets: { - password: 'elastic', - username: 'changeme', - }, - params: { - subAction: 'pushToService', - subActionParams: { - incident: { - description: 'a description', - externalId: null, - impact: '1', - severity: '1', - short_description: 'a title', - urgency: '1', - category: 'software', - subcategory: 'software', - }, - comments: [ - { - comment: 'first comment', - commentId: '456', - }, - ], - }, - }, - }; - - testConnectorCreation(connectorWithParams, '.servicenow'); - testExecuteValidation(connectorWithParams, '.servicenow'); - testExecute(connectorWithParams, '.servicenow'); - }); - - describe('ServiceNow SecOps', () => { - const connectorWithParams = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - isLegacy: false, - }, - secrets: { - password: 'elastic', - username: 'changeme', - }, - params: { - subAction: 'pushToService', - subActionParams: { - incident: { - externalId: null, - short_description: 'Incident title', - description: 'Incident description', - dest_ip: ['192.168.1.1', '192.168.1.3'], - source_ip: ['192.168.1.2', '192.168.1.4'], - malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], - malware_url: ['https://example.com'], - category: 'software', - subcategory: 'os', - correlation_id: 'ruleId', - correlation_display: 'Alerting', - priority: '1', - }, - comments: [ - { - comment: 'first comment', - commentId: '456', - }, - ], - }, - }, - }; - - testConnectorCreation(connectorWithParams, '.servicenow-sir'); - testExecuteValidation(connectorWithParams, '.servicenow-sir'); - testExecute(connectorWithParams, '.servicenow-sir'); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts new file mode 100644 index 0000000000000..eee3425b6a61f --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow_sir.ts @@ -0,0 +1,544 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; +import getPort from 'get-port'; +import http from 'http'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function serviceNowSIRTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + isLegacy: false, + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + externalId: null, + short_description: 'Incident title', + description: 'Incident description', + dest_ip: ['192.168.1.1', '192.168.1.3'], + source_ip: ['192.168.1.2', '192.168.1.4'], + malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'], + malware_url: ['https://example.com'], + category: 'software', + subcategory: 'os', + correlation_id: 'alertID', + correlation_display: 'Alerting', + priority: '1', + }, + comments: [ + { + comment: 'first comment', + commentId: '456', + }, + ], + }, + }, + }; + + describe('ServiceNow SIR', () => { + let simulatedActionId = ''; + let serviceNowSimulatorURL: string = ''; + let serviceNowServer: http.Server; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + before(async () => { + serviceNowServer = await getServiceNowServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + if (!serviceNowServer.listening) { + serviceNowServer.listen(availablePort); + } + serviceNowSimulatorURL = `http://localhost:${availablePort}`; + proxyServer = await getHttpProxyServer( + serviceNowSimulatorURL, + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + after(() => { + serviceNowServer.close(); + if (proxyServer) { + proxyServer.close(); + } + }); + + describe('ServiceNow SIR - Action Creation', () => { + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + is_missing_secrets: false, + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + }); + }); + + it('should set the isLegacy to false when not provided', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction.config.isLegacy).to.be(false); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + secrets: mockServiceNow.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [password]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('ServiceNow SIR - Executor', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: false, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.status).to.eql('error'); + expect(resp.body.retry).to.eql(false); + // Node.js 12 oddity: + // + // The first time after the server is booted, the error message will be: + // + // undefined is not iterable (cannot read property Symbol(Symbol.iterator)) + // + // After this, the error will be: + // + // Cannot destructure property 'value' of 'undefined' as it is undefined. + // + // The error seems to come from the exact same place in the code based on the + // exact same circumstances: + // + // https://github.com/elastic/kibana/blob/b0a223ebcbac7e404e8ae6da23b2cc6a4b509ff1/packages/kbn-config-schema/src/types/literal_type.ts#L28 + // + // What triggers the error is that the `handleError` function expects its 2nd + // argument to be an object containing a `valids` property of type array. + // + // In this test the object does not contain a `valids` property, so hence the + // error. + // + // Why the error message isn't the same in all scenarios is unknown to me and + // could be a bug in V8. + expect(resp.body.message).to.match( + /^error validating action params: (undefined is not iterable \(cannot read property Symbol\(Symbol.iterator\)\)|Cannot destructure property 'value' of 'undefined' as it is undefined\.)$/ + ); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ comment: 'boo' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: { + ...mockServiceNow.params.subActionParams.incident, + short_description: 'success', + }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]', + }); + }); + }); + + describe('getChoices', () => { + it('should fail when field is not provided', async () => { + await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: {}, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + connector_id: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]', + }); + }); + }); + }); + }); + + describe('Execution', () => { + // New connectors + describe('Import set API', () => { + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, + }, + }); + }); + }); + + // Legacy connectors + describe('Table API', () => { + before(async () => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + connector_type_id: '.servicenow-sir', + config: { + apiUrl: serviceNowSimulatorURL, + isLegacy: true, + }, + secrets: mockServiceNow.secrets, + }); + simulatedActionId = body.id; + }); + + it('should handle creating an incident without comments', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNow.params, + subActionParams: { + incident: mockServiceNow.params.subActionParams.incident, + comments: [], + }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + id: '123', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${serviceNowSimulatorURL}/nav_to.do?uri=sn_si_incident.do?sys_id=123`, + }, + }); + }); + }); + + describe('getChoices', () => { + it('should get choices', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getChoices', + subActionParams: { fields: ['priority'] }, + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + }, + { + dependent_value: '', + label: '5 - Planning', + value: '5', + }, + ], + }); + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index db57af0ba1a98..61bd1bcad34ad 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -25,7 +25,8 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/pagerduty')); loadTestFile(require.resolve('./builtin_action_types/swimlane')); loadTestFile(require.resolve('./builtin_action_types/server_log')); - loadTestFile(require.resolve('./builtin_action_types/servicenow')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm')); + loadTestFile(require.resolve('./builtin_action_types/servicenow_sir')); loadTestFile(require.resolve('./builtin_action_types/jira')); loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); From a801944e2537817ffdfb5c71db94cb5b2cea4390 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 12 Oct 2021 12:56:41 +0300 Subject: [PATCH 92/92] PR feedback --- .../servicenow/api.test.ts | 38 ++++++++++++------- .../servicenow/servicenow_connectors.tsx | 8 +--- .../servicenow/use_get_app_info.test.tsx | 13 +++---- .../builtin_action_types/swimlane/api.test.ts | 25 ++++++------ 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts index a60bafbb043fc..4b67d256d99bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.test.ts @@ -133,37 +133,47 @@ describe('ServiceNow API', () => { }); it('returns an error when the response fails', async () => { + expect.assertions(1); + const abortCtrl = new AbortController(); - fetchMock.mockResolvedValueOnce(applicationInfoResponse); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => applicationInfoResponse.json, + }); - try { - await getAppInfo({ + await expect(() => + getAppInfo({ signal: abortCtrl.signal, apiUrl: 'https://example.com', username: 'test', password: 'test', actionTypeId: '.servicenow', - }); - } catch (e) { - expect(e.message).toContain('Received status:'); - } + }) + ).rejects.toThrow('Received status:'); }); it('returns an error when parsing the json fails', async () => { + expect.assertions(1); + const abortCtrl = new AbortController(); - fetchMock.mockResolvedValueOnce(applicationInfoResponse); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('bad'); + }, + }); - try { - await getAppInfo({ + await expect(() => + getAppInfo({ signal: abortCtrl.signal, apiUrl: 'https://example.com', username: 'test', password: 'test', actionTypeId: '.servicenow', - }); - } catch (e) { - expect(e.message).toContain('bad'); - } + }) + ).rejects.toThrow('bad'); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index cada62017a500..2cf738c5e0c13 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -74,13 +74,9 @@ const ServiceNowConnectorFields: React.FC { - // TODO: Implement - }, []); - useEffect( - () => setCallbacks({ beforeActionConnectorSave, afterActionConnectorSave }), - [afterActionConnectorSave, beforeActionConnectorSave, setCallbacks] + () => setCallbacks({ beforeActionConnectorSave }), + [beforeActionConnectorSave, setCallbacks] ); const onMigrateClick = useCallback(() => setShowModal(true), []); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx index 615f983c1564f..c6b70443ec8fb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/use_get_app_info.test.tsx @@ -75,6 +75,7 @@ describe('useGetAppInfo', () => { }); it('it throws an error when api fails', async () => { + expect.assertions(1); getAppInfoMock.mockImplementation(() => { throw new Error('An error occurred'); }); @@ -85,14 +86,10 @@ describe('useGetAppInfo', () => { }) ); - try { - await act(async () => { + await expect(() => + act(async () => { await result.current.fetchAppInfo(actionConnector); - }); - - fail('Should never get here'); - } catch (e) { - expect(e.message).toBe('An error occurred'); - } + }) + ).rejects.toThrow('An error occurred'); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts index 90bab65b83bfd..00262c3265d7a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/api.test.ts @@ -39,29 +39,28 @@ describe('Swimlane API', () => { }); it('returns an error when the response fails', async () => { + expect.assertions(1); const abortCtrl = new AbortController(); - fetchMock.mockResolvedValueOnce({ ok: false, status: 401, json: async () => getApplicationResponse, }); - try { - await getApplication({ + await expect(() => + getApplication({ signal: abortCtrl.signal, apiToken: '', appId: '', url: '', - }); - } catch (e) { - expect(e.message).toContain('Received status:'); - } + }) + ).rejects.toThrow('Received status:'); }); it('returns an error when parsing the json fails', async () => { - const abortCtrl = new AbortController(); + expect.assertions(1); + const abortCtrl = new AbortController(); fetchMock.mockResolvedValueOnce({ ok: true, status: 200, @@ -70,16 +69,14 @@ describe('Swimlane API', () => { }, }); - try { - await getApplication({ + await expect(() => + getApplication({ signal: abortCtrl.signal, apiToken: '', appId: '', url: '', - }); - } catch (e) { - expect(e.message).toContain('bad'); - } + }) + ).rejects.toThrow('bad'); }); it('it removes unsafe fields', async () => {