diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index 10f8265b15cc3..06b23beca7af4 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -26462,6 +26462,85 @@ Object { "presence": "optional", }, "keys": Object { + "additional_fields": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-get-additional-properties": [Function], + }, + ], + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + ], + "type": "any", + }, + }, + "name": "entries", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "record", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, "category": Object { "flags": Object { "default": null, @@ -28617,6 +28696,85 @@ Object { "presence": "optional", }, "keys": Object { + "additional_fields": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-get-additional-properties": [Function], + }, + ], + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "metas": Array [ + Object { + "x-oas-any-type": true, + }, + ], + "type": "any", + }, + }, + "name": "entries", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "record", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, "category": Object { "flags": Object { "default": null, diff --git a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts index 3a11325d78b1a..e15bb41de7fb6 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/rules/alert_actions.ts @@ -222,6 +222,7 @@ function getServiceNowActionParams({ defaultActionMessage }: Translations): Serv externalId: null, correlation_id: null, correlation_display: null, + additional_fields: null, }, comments: [], }, diff --git a/x-pack/plugins/observability_solution/uptime/common/rules/alert_actions.ts b/x-pack/plugins/observability_solution/uptime/common/rules/alert_actions.ts index 007a49be1ba81..767e9d208c56f 100644 --- a/x-pack/plugins/observability_solution/uptime/common/rules/alert_actions.ts +++ b/x-pack/plugins/observability_solution/uptime/common/rules/alert_actions.ts @@ -214,6 +214,7 @@ function getServiceNowActionParams({ defaultActionMessage }: Translations): Serv externalId: null, correlation_id: null, correlation_display: null, + additional_fields: null, }, comments: [], }, diff --git a/x-pack/plugins/stack_connectors/common/servicenow/constants.ts b/x-pack/plugins/stack_connectors/common/servicenow/constants.ts new file mode 100644 index 0000000000000..adc6981bf381a --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/servicenow/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 MAX_ADDITIONAL_FIELDS_LENGTH = 20; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.test.tsx index 15f84f781e41e..0b2227011ab89 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.test.tsx @@ -89,9 +89,7 @@ describe('jira action params validation', () => { errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [], - 'subActionParams.incident.otherFields': [ - 'Additional fields field must be a valid JSON object.', - ], + 'subActionParams.incident.otherFields': ['Invalid JSON.'], }, }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.tsx b/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.tsx index a106514b856a2..ecbf0f0f848b4 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/jira/jira.tsx @@ -13,6 +13,7 @@ import type { } from '@kbn/triggers-actions-ui-plugin/public'; import { MAX_OTHER_FIELDS_LENGTH } from '../../../common/jira/constants'; import { JiraConfig, JiraSecrets, JiraActionParams } from './types'; +import { validateJSON } from '../lib/validate_json'; export const JIRA_DESC = i18n.translate('xpack.stackConnectors.components.jira.selectMessageText', { defaultMessage: 'Create an incident in Jira.', @@ -58,19 +59,15 @@ export function getConnectorType(): ConnectorTypeModel MAX_OTHER_FIELDS_LENGTH) { - errors['subActionParams.incident.otherFields'] = [ - translations.OTHER_FIELDS_LENGTH_ERROR(MAX_OTHER_FIELDS_LENGTH), - ]; - } - } - } catch (error) { - errors['subActionParams.incident.otherFields'] = [translations.INVALID_JSON_FORMAT]; + const jsonErrors = validateJSON({ + value: actionParams.subActionParams?.incident?.otherFields, + maxProperties: MAX_OTHER_FIELDS_LENGTH, + }); + + if (jsonErrors) { + errors['subActionParams.incident.otherFields'] = [jsonErrors]; } + return validationResult; }, actionParamsFields: lazy(() => import('./jira_params')), diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/additional_fields.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/additional_fields.test.tsx new file mode 100644 index 0000000000000..33f4fe3923b88 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/additional_fields.test.tsx @@ -0,0 +1,91 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { AdditionalFields } from './additional_fields'; +import userEvent from '@testing-library/user-event'; + +describe('Credentials', () => { + const onChange = jest.fn(); + const value = JSON.stringify({ foo: 'test' }); + const props = { value, errors: [], onChange }; + + it('renders the additional fields correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('additionalFields')).toBeInTheDocument(); + }); + + it('sets the value correctly', async () => { + render( + + + + ); + + expect(await screen.findByText(value)).toBeInTheDocument(); + }); + + /** + * Test for the intermediate release process + */ + it('does not show the component if the value is undefined', async () => { + render( + + + + ); + + expect(screen.queryByTestId('additional_fieldsJsonEditor')).not.toBeInTheDocument(); + }); + + it('changes the value correctly', async () => { + const newValue = JSON.stringify({ bar: 'test' }); + + render( + + + + ); + + const editor = await screen.findByTestId('additional_fieldsJsonEditor'); + + userEvent.clear(editor); + userEvent.paste(editor, newValue); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(newValue); + }); + + expect(await screen.findByText(newValue)).toBeInTheDocument(); + }); + + it('updating wth an empty string sets its value to null', async () => { + const newValue = JSON.stringify({ bar: 'test' }); + + render( + + + + ); + + const editor = await screen.findByTestId('additional_fieldsJsonEditor'); + + userEvent.paste(editor, newValue); + userEvent.clear(editor); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/additional_fields.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/additional_fields.tsx new file mode 100644 index 0000000000000..b9d3602112c53 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/additional_fields.tsx @@ -0,0 +1,64 @@ +/* + * 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 { EuiIconTip } from '@elastic/eui'; +import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import React from 'react'; +import { ActionVariable } from '@kbn/alerting-types'; +import { isEmpty } from 'lodash'; +import * as i18n from './translations'; + +interface AdditionalFieldsProps { + value?: string | null; + errors?: string[]; + messageVariables?: ActionVariable[]; + onChange: (value: string | null) => void; +} + +export const AdditionalFieldsComponent: React.FC = ({ + value, + errors, + messageVariables, + onChange, +}) => { + /** + * Hide the component if the value is not defined. + * This is needed for the intermediate release process. + * Users will not be able to use the field if they have never set it. + * On the next Serverless release the check will be removed. + */ + if (value === undefined) { + return null; + } + + return ( + + {i18n.ADDITIONAL_FIELDS} + + + } + onDocumentsChange={(json: string) => onChange(isEmpty(json) ? null : json)} + /> + ); +}; + +export const AdditionalFields = React.memo(AdditionalFieldsComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/translations.ts index 8f519e9661881..2f81caa117f26 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/translations.ts @@ -441,3 +441,32 @@ export const ADDITIONAL_INFO_JSON_ERROR = i18n.translate( defaultMessage: 'The additional info field does not have a valid JSON format.', } ); + +export const ADDITIONAL_FIELDS = i18n.translate( + 'xpack.stackConnectors.components.servicenow.additionalFieldsTooltip', + { + defaultMessage: 'Additional fields', + } +); + +export const ADDITIONAL_FIELDS_HELP = i18n.translate( + 'xpack.stackConnectors.components.servicenow.additionalFieldsHelpTooltip', + { + defaultMessage: 'Additional fields help', + } +); + +export const ADDITIONAL_FIELDS_HELP_TEXT = i18n.translate( + 'xpack.stackConnectors.components.servicenow.additionalFieldsHelpTooltipText', + { + defaultMessage: + 'Additional fields in JSON format as defined in the Elastic ServiceNow application', + } +); + +export const ADDITIONAL_FIELDS_JSON_ERROR = i18n.translate( + 'xpack.stackConnectors.components.servicenow.additionalFieldsError', + { + defaultMessage: 'No valid JSON.', + } +); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/validate_json.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/validate_json.test.ts new file mode 100644 index 0000000000000..4e3de337cab1a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/validate_json.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { validateJSON } from './validate_json'; + +describe('validateJSON', () => { + it('does not return an error for valid JSON and no maxProperties', () => { + expect(validateJSON({ value: JSON.stringify({ foo: 'test' }) })).toBeUndefined(); + }); + + it('does not return an error for valid JSON and attributes less than maxProperties', () => { + expect( + validateJSON({ value: JSON.stringify({ foo: 'test' }), maxProperties: 1 }) + ).toBeUndefined(); + }); + + it('does not return an error with empty value and maxProperties=0', () => { + expect(validateJSON({ maxProperties: 0 })).toBeUndefined(); + }); + + it('does not return an error with no values', () => { + expect(validateJSON({})).toBeUndefined(); + }); + + it('does not return an error with empty object and maxProperties=0', () => { + expect(validateJSON({ value: JSON.stringify({}), maxProperties: 0 })).toBeUndefined(); + }); + + it('validates syntax errors correctly', () => { + expect(validateJSON({ value: 'foo' })).toBe('Invalid JSON.'); + }); + + it('validates max properties correctly', () => { + const value = { foo: 'test', bar: 'test 2' }; + + expect(validateJSON({ value: JSON.stringify(value), maxProperties: 1 })).toBe( + 'A maximum of 1 additional fields can be defined at a time.' + ); + }); + + it('does not return an error for an object', () => { + expect(validateJSON({ value: { foo: 'test' } })).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/lib/validate_json.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/validate_json.ts new file mode 100644 index 0000000000000..c4ff59b48bb82 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/validate_json.ts @@ -0,0 +1,49 @@ +/* + * 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 { isPlainObject } from 'lodash'; +import { i18n } from '@kbn/i18n'; + +interface ValidateJSONArgs { + value?: string | null | Record; + maxProperties?: number; +} + +const isObject = (value?: ValidateJSONArgs['value']): value is Record => { + return isPlainObject(value); +}; + +export const MAX_ATTRIBUTES_ERROR = (length: number) => + i18n.translate('xpack.stackConnectors.schema.additionalFieldsLengthError', { + values: { length }, + defaultMessage: 'A maximum of {length} additional fields can be defined at a time.', + }); + +export const INVALID_JSON_FORMAT = i18n.translate( + 'xpack.stackConnectors.components.otherFieldsFormatErrorMessage', + { + defaultMessage: 'Invalid JSON.', + } +); + +export const validateJSON = ({ value, maxProperties }: ValidateJSONArgs) => { + try { + if (isObject(value)) { + return; + } + + if (value) { + const parsedOtherFields = JSON.parse(value); + + if (maxProperties && Object.keys(parsedOtherFields).length > maxProperties) { + return MAX_ATTRIBUTES_ERROR(maxProperties); + } + } + } catch (error) { + return INVALID_JSON_FORMAT; + } +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/opsgenie/create_alert/tags.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/opsgenie/create_alert/tags.test.tsx index dcfd95aab0caf..d006ff23aed5f 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/opsgenie/create_alert/tags.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/opsgenie/create_alert/tags.test.tsx @@ -122,7 +122,9 @@ describe('Tags', () => { }); act(() => { - userEvent.click(screen.getByText('The tags of the rule.')); + userEvent.click(screen.getByText('The tags of the rule.'), undefined, { + skipPointerEventsCheck: true, + }); }); await waitFor(() => diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.test.tsx index 107ccab01e60c..659ad5961f2ff 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.test.tsx @@ -10,6 +10,7 @@ import { registerConnectorTypes } from '..'; import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; import { experimentalFeaturesMock, registrationServicesMock } from '../../mocks'; import { ExperimentalFeaturesService } from '../../common/experimental_features_service'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '../../../common/servicenow/constants'; const SERVICENOW_ITSM_CONNECTOR_TYPE_ID = '.servicenow'; let connectorTypeRegistry: TypeRegistry; @@ -31,6 +32,7 @@ describe('servicenow action params validation', () => { test(`${SERVICENOW_ITSM_CONNECTOR_TYPE_ID}: action params validation succeeds when action params is valid`, async () => { const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); const actionParams = { + subAction: 'pushToService', subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, }; @@ -38,6 +40,7 @@ describe('servicenow action params validation', () => { errors: { ['subActionParams.incident.correlation_id']: [], ['subActionParams.incident.short_description']: [], + ['subActionParams.incident.additional_fields']: [], }, }); }); @@ -53,6 +56,7 @@ describe('servicenow action params validation', () => { errors: { ['subActionParams.incident.correlation_id']: [], ['subActionParams.incident.short_description']: [], + ['subActionParams.incident.additional_fields']: [], }, }); }); @@ -67,6 +71,7 @@ describe('servicenow action params validation', () => { errors: { ['subActionParams.incident.correlation_id']: [], ['subActionParams.incident.short_description']: ['Short description is required.'], + ['subActionParams.incident.additional_fields']: [], }, }); }); @@ -82,6 +87,52 @@ describe('servicenow action params validation', () => { errors: { ['subActionParams.incident.correlation_id']: ['Correlation id is required.'], ['subActionParams.incident.short_description']: [], + ['subActionParams.incident.additional_fields']: [], + }, + }); + }); + + test('params validation fails when additional_fields is not valid JSON', async () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); + const actionParams = { + subAction: 'pushToService', + subActionParams: { + incident: { short_description: 'some title', additional_fields: 'invalid json' }, + }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.correlation_id': [], + 'subActionParams.incident.short_description': [], + 'subActionParams.incident.additional_fields': ['Invalid JSON.'], + }, + }); + }); + + test(`params validation succeeds when its valid json and additional_fields has ${ + MAX_ADDITIONAL_FIELDS_LENGTH + 1 + } fields`, async () => { + const longJSON: { [key in string]: string } = {}; + for (let i = 0; i < MAX_ADDITIONAL_FIELDS_LENGTH + 1; i++) { + longJSON[`key${i}`] = 'value'; + } + + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); + const actionParams = { + subAction: 'pushToService', + subActionParams: { + incident: { short_description: 'some title', additional_fields: JSON.stringify(longJSON) }, + }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.correlation_id': [], + 'subActionParams.incident.short_description': [], + ['subActionParams.incident.additional_fields']: [ + `A maximum of ${MAX_ADDITIONAL_FIELDS_LENGTH} additional fields can be defined at a time.`, + ], }, }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.tsx index dfa6eb5c43987..c6bcf060c55b4 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm.tsx @@ -11,6 +11,7 @@ import type { ActionTypeModel as ConnectorTypeModel, GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '../../../common/servicenow/constants'; import { ServiceNowConfig, ServiceNowSecrets } from '../lib/servicenow/types'; import { ServiceNowITSMActionParams } from './types'; import { @@ -18,6 +19,7 @@ import { getConnectorDescriptiveTitle, getSelectedConnectorIcon, } from '../lib/servicenow/helpers'; +import { validateJSON } from '../lib/validate_json'; export const SERVICENOW_ITSM_DESC = i18n.translate( 'xpack.stackConnectors.components.serviceNowITSM.selectMessageText', @@ -51,10 +53,13 @@ export function getServiceNowITSMConnectorType(): ConnectorTypeModel< const errors = { 'subActionParams.incident.short_description': new Array(), 'subActionParams.incident.correlation_id': new Array(), + 'subActionParams.incident.additional_fields': new Array(), }; + const validationResult = { errors, }; + if ( actionParams.subActionParams && actionParams.subActionParams.incident && @@ -72,6 +77,16 @@ export function getServiceNowITSMConnectorType(): ConnectorTypeModel< translations.CORRELATION_ID_REQUIRED ); } + + const jsonErrors = validateJSON({ + value: actionParams.subActionParams?.incident?.additional_fields, + maxProperties: MAX_ADDITIONAL_FIELDS_LENGTH, + }); + + if (jsonErrors) { + errors['subActionParams.incident.additional_fields'] = [jsonErrors]; + } + return validationResult; }, actionParamsFields: lazy(() => import('./servicenow_itsm_params')), diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.test.tsx index e0eb4524341dc..26ec00f07e684 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { act, waitFor } from '@testing-library/react'; +import { act, render, waitFor, screen } from '@testing-library/react'; import { merge } from 'lodash'; import { ActionConnector, ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -15,6 +15,8 @@ import { useGetChoices } from '../lib/servicenow/use_get_choices'; import ServiceNowITSMParamsFields from './servicenow_itsm_params'; import { Choice } from '../lib/servicenow/types'; import { ACTION_GROUP_RECOVERED } from '../lib/servicenow/helpers'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@kbn/i18n-react'; jest.mock('../lib/servicenow/use_get_choices'); jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); @@ -35,6 +37,7 @@ const actionParams = { externalId: null, correlation_id: 'alertID', correlation_display: 'Alerting', + additional_fields: null, }, comments: [], }, @@ -366,6 +369,19 @@ describe('ServiceNowITSMParamsFields renders', () => { expect(wrapper.find('.euiFormErrorText').text()).toBe('correlation_id_error'); }); + + it('updates additional fields', async () => { + const newValue = JSON.stringify({ bar: 'test' }); + render(, { + wrapper: ({ children }) => {children}, + }); + + userEvent.paste(await screen.findByTestId('additional_fieldsJsonEditor'), newValue); + + await waitFor(() => { + expect(editAction.mock.calls[0][1].incident.additional_fields).toEqual(newValue); + }); + }); }); describe('Test form', () => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.tsx index 2c3ebc0805930..b08809e2cb5ef 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/servicenow_itsm_params.tsx @@ -34,6 +34,7 @@ import { } from '../lib/servicenow/helpers'; import * as i18n from '../lib/servicenow/translations'; +import { AdditionalFields } from '../lib/servicenow/additional_fields'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -263,6 +264,11 @@ const ServiceNowParamsFields: React.FunctionComponent< // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionConnector, isTestResolveAction, isTestTriggerAction]); + const additionalFieldsOnChange = useCallback( + (value) => editSubActionProperty('additional_fields', value), + [editSubActionProperty] + ); + return ( <> {executionMode === ActionConnectorMode.Test ? ( @@ -451,6 +457,14 @@ const ServiceNowParamsFields: React.FunctionComponent< inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined} label={i18n.COMMENTS_LABEL} /> + {!isDeprecatedActionConnector && ( + + )} )} {showOnlyCorrelationId && ( diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/types.ts index dd0a47004f359..11d93f9d74388 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/types.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_itsm/types.ts @@ -14,5 +14,10 @@ export enum EventAction { export interface ServiceNowITSMActionParams { subAction: string; - subActionParams: ExecutorSubActionPushParamsITSM; + /* We override "additional_fields" to string because when users fill in the form, the structure won't match until done and + we need to store the current state. To match with the data structure define in the backend, we make sure users can't + send the form while not matching the original object structure. */ + subActionParams: ExecutorSubActionPushParamsITSM & { + incident: { additional_fields: string | null }; + }; } diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.test.tsx index b2cfd79447ff8..76158882575b9 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.test.tsx @@ -10,6 +10,7 @@ import { registerConnectorTypes } from '..'; import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; import { experimentalFeaturesMock, registrationServicesMock } from '../../mocks'; import { ExperimentalFeaturesService } from '../../common/experimental_features_service'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '../../../common/servicenow/constants'; const SERVICENOW_SIR_CONNECTOR_TYPE_ID = '.servicenow-sir'; let connectorTypeRegistry: TypeRegistry; @@ -35,7 +36,10 @@ describe('servicenow action params validation', () => { }; expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ - errors: { ['subActionParams.incident.short_description']: [] }, + errors: { + ['subActionParams.incident.short_description']: [], + ['subActionParams.incident.additional_fields']: [], + }, }); }); @@ -48,6 +52,48 @@ describe('servicenow action params validation', () => { expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: ['Short description is required.'], + ['subActionParams.incident.additional_fields']: [], + }, + }); + }); + + test('params validation fails when additional_fields is not valid JSON', async () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_SIR_CONNECTOR_TYPE_ID); + const actionParams = { + subActionParams: { + incident: { short_description: 'some title', additional_fields: 'invalid json' }, + }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.short_description': [], + 'subActionParams.incident.additional_fields': ['Invalid JSON.'], + }, + }); + }); + + test(`params validation succeeds when its valid json and additional_fields has ${ + MAX_ADDITIONAL_FIELDS_LENGTH + 1 + } fields`, async () => { + const longJSON: { [key in string]: string } = {}; + for (let i = 0; i < MAX_ADDITIONAL_FIELDS_LENGTH + 1; i++) { + longJSON[`key${i}`] = 'value'; + } + + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_SIR_CONNECTOR_TYPE_ID); + const actionParams = { + subActionParams: { + incident: { short_description: 'some title', additional_fields: JSON.stringify(longJSON) }, + }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.incident.short_description': [], + ['subActionParams.incident.additional_fields']: [ + `A maximum of ${MAX_ADDITIONAL_FIELDS_LENGTH} additional fields can be defined at a time.`, + ], }, }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.tsx index 9efbd1bf2a8bd..72144204e924f 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir.tsx @@ -11,9 +11,11 @@ import type { ActionTypeModel as ConnectorTypeModel, GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '../../../common/servicenow/constants'; import { ServiceNowConfig, ServiceNowSecrets } from '../lib/servicenow/types'; import { ServiceNowSIRActionParams } from './types'; import { getConnectorDescriptiveTitle, getSelectedConnectorIcon } from '../lib/servicenow/helpers'; +import { validateJSON } from '../lib/validate_json'; export const SERVICENOW_SIR_DESC = i18n.translate( 'xpack.stackConnectors.components.serviceNowSIR.selectMessageText', @@ -46,7 +48,9 @@ export function getServiceNowSIRConnectorType(): ConnectorTypeModel< const translations = await import('../lib/servicenow/translations'); const errors = { 'subActionParams.incident.short_description': new Array(), + 'subActionParams.incident.additional_fields': new Array(), }; + const validationResult = { errors, }; @@ -57,6 +61,16 @@ export function getServiceNowSIRConnectorType(): ConnectorTypeModel< ) { errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } + + const jsonErrors = validateJSON({ + value: actionParams.subActionParams?.incident?.additional_fields, + maxProperties: MAX_ADDITIONAL_FIELDS_LENGTH, + }); + + if (jsonErrors) { + errors['subActionParams.incident.additional_fields'] = [jsonErrors]; + } + return validationResult; }, actionParamsFields: lazy(() => import('./servicenow_sir_params')), diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.test.tsx index 8a62b6f09a758..e6406c49988c6 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { act } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -14,6 +14,8 @@ import { useGetChoices } from '../lib/servicenow/use_get_choices'; import ServiceNowSIRParamsFields from './servicenow_sir_params'; import { Choice } from '../lib/servicenow/types'; import { merge } from 'lodash'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@kbn/i18n-react'; jest.mock('../lib/servicenow/use_get_choices'); jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); @@ -36,6 +38,7 @@ const actionParams = { externalId: null, correlation_id: 'alertID', correlation_display: 'Alerting', + additional_fields: null, }, comments: [], }, @@ -341,5 +344,18 @@ describe('ServiceNowSIRParamsFields renders', () => { expect(comments.simulate('change', changeEvent)); expect(editAction.mock.calls[0][1].comments.length).toEqual(1); }); + + it('updates additional fields', async () => { + const newValue = JSON.stringify({ bar: 'test' }); + render(, { + wrapper: ({ children }) => {children}, + }); + + userEvent.paste(await screen.findByTestId('additional_fieldsJsonEditor'), newValue); + + await waitFor(() => { + expect(editAction.mock.calls[0][1].incident.additional_fields).toEqual(newValue); + }); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.tsx index dcf296e7bf4a5..d4fbb732347a1 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/servicenow_sir_params.tsx @@ -29,6 +29,7 @@ import { ServiceNowSIRActionParams } from './types'; import { Fields, Choice } from '../lib/servicenow/types'; import { choicesToEuiOptions, DEFAULT_CORRELATION_ID } from '../lib/servicenow/helpers'; import { DeprecatedCallout } from '../lib/servicenow/deprecated_callout'; +import { AdditionalFields } from '../lib/servicenow/additional_fields'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -148,6 +149,11 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionParams]); + const additionalFieldsOnChange = useCallback( + (value) => editSubActionProperty('additional_fields', value), + [editSubActionProperty] + ); + return ( <> {isDeprecatedActionConnector && } @@ -288,6 +294,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< label={i18n.COMMENTS_LABEL} /> + {!isDeprecatedActionConnector && ( + + )} ); }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/types.ts index acc318117f7fe..78c2e94f3d990 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/types.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/servicenow_sir/types.ts @@ -9,5 +9,10 @@ import type { ExecutorSubActionPushParamsSIR } from '../../../server/connector_t export interface ServiceNowSIRActionParams { subAction: string; - subActionParams: ExecutorSubActionPushParamsSIR; + /* We override "additional_fields" to string because when users fill in the form, the structure won't match until done and + we need to store the current state. To match with the data structure define in the backend, we make sure users can't + send the form while not matching the original object structure. */ + subActionParams: ExecutorSubActionPushParamsSIR & { + incident: { additional_fields: string | null }; + }; } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.test.ts index 6ae20ef3cd5d8..2cba126465302 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.test.ts @@ -55,7 +55,7 @@ describe('Jira schema', () => { otherFields, }, }) - ).toThrow('A maximum of 20 otherFields can be defined at a time.'); + ).toThrow('A maximum of 20 fields in otherFields can be defined at a time.'); }); it.each(incidentSchemaObjectProperties)( diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.ts index 519a10ef8576e..aebd5f18c9615 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/schema.ts @@ -6,7 +6,9 @@ */ import { schema } from '@kbn/config-schema'; -import { validateOtherFieldsKeys, validateOtherFieldsLength } from './validators'; +import { MAX_OTHER_FIELDS_LENGTH } from '../../../common/jira/constants'; +import { validateRecordMaxKeys } from '../lib/validators'; +import { validateOtherFieldsKeys } from './validators'; export const ExternalIncidentServiceConfiguration = { apiUrl: schema.string(), @@ -49,7 +51,12 @@ const incidentSchemaObject = { }), schema.any(), { - validate: (value) => validateOtherFieldsLength(value), + validate: (value) => + validateRecordMaxKeys({ + record: value, + maxNumberOfFields: MAX_OTHER_FIELDS_LENGTH, + fieldName: 'otherFields', + }), } ) ), diff --git a/x-pack/plugins/stack_connectors/server/connector_types/jira/validators.ts b/x-pack/plugins/stack_connectors/server/connector_types/jira/validators.ts index d59af47da48f5..5daa0a13f34c6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/jira/validators.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/jira/validators.ts @@ -13,8 +13,8 @@ import { } from './types'; import * as i18n from './translations'; -import { MAX_OTHER_FIELDS_LENGTH } from '../../../common/jira/constants'; import { incidentSchemaObjectProperties } from './schema'; +import { validateKeysAllowed } from '../lib/validators'; export const validateCommonConfig = ( configObject: JiraPublicConfigurationType, @@ -38,18 +38,10 @@ export const validate: ExternalServiceValidation = { secrets: validateCommonSecrets, }; -export const validateOtherFieldsLength = ( - otherFields: Record -): string | undefined => { - if (Object.keys(otherFields).length > MAX_OTHER_FIELDS_LENGTH) { - return i18n.OTHER_FIELDS_LENGTH_ERROR(MAX_OTHER_FIELDS_LENGTH); - } -}; - export const validateOtherFieldsKeys = (key: string): string | undefined => { - const propertiesSet = new Set(incidentSchemaObjectProperties); - - if (propertiesSet.has(key)) { - return i18n.OTHER_FIELDS_PROPERTY_ERROR(key); - } + return validateKeysAllowed({ + key, + disallowList: incidentSchemaObjectProperties, + fieldName: 'otherFields', + }); }; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.test.ts index fefe78df73574..ef4953189fee6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.test.ts @@ -99,6 +99,7 @@ describe('api', () => { correlation_display: 'Alerting', correlation_id: 'ruleId', opened_by: 'elastic', + additional_fields: {}, }, }); expect(externalService.updateIncident).not.toHaveBeenCalled(); @@ -114,6 +115,7 @@ describe('api', () => { logger: mockedLogger, commentFieldKey: 'comments', }); + expect(externalService.updateIncident).toHaveBeenCalledTimes(2); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { incident: { @@ -127,6 +129,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-1', }); @@ -143,6 +146,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-1', }); @@ -171,6 +175,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-1', }); @@ -187,6 +192,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-1', }); @@ -264,6 +270,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, }); expect(externalService.createIncident).not.toHaveBeenCalled(); @@ -291,6 +298,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-3', }); @@ -307,6 +315,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-2', }); @@ -334,6 +343,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-3', }); @@ -350,6 +360,7 @@ describe('api', () => { short_description: 'Incident title', correlation_display: 'Alerting', correlation_id: 'ruleId', + additional_fields: {}, }, incidentId: 'incident-2', }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/mocks.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/mocks.ts index 410a5f58ab00b..8e81892ff5098 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/mocks.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/mocks.ts @@ -205,6 +205,7 @@ export const executorParams: ExecutorSubActionPushParams = { subcategory: 'os', correlation_id: 'ruleId', correlation_display: 'Alerting', + additional_fields: {}, }, comments: [ { @@ -232,6 +233,7 @@ export const sirParams: PushToServiceApiParamsSIR = { correlation_id: 'ruleId', correlation_display: 'Alerting', priority: '1', + additional_fields: {}, }, comments: [ { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/schema.ts index 568d9b01e67e6..e5a13956a0eb4 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/schema.ts @@ -6,7 +6,10 @@ */ import { schema } from '@kbn/config-schema'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '../../../../common/servicenow/constants'; +import { validateRecordMaxKeys } from '../validators'; import { DEFAULT_ALERTS_GROUPING_KEY } from './config'; +import { validateOtherFieldsKeys } from './validators'; export const ExternalIncidentServiceConfigurationBase = { apiUrl: schema.string(), @@ -58,8 +61,26 @@ const CommonAttributes = { subcategory: schema.nullable(schema.string()), correlation_id: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })), correlation_display: schema.nullable(schema.string()), + additional_fields: schema.nullable( + schema.recordOf( + schema.string({ + validate: (value) => validateOtherFieldsKeys(value), + }), + schema.any(), + { + validate: (value) => + validateRecordMaxKeys({ + record: value, + maxNumberOfFields: MAX_ADDITIONAL_FIELDS_LENGTH, + fieldName: 'additional_fields', + }), + } + ) + ), }; +export const commonIncidentSchemaObjectProperties = Object.keys(CommonAttributes); + // Schema for ServiceNow Incident Management (ITSM) export const ExecutorSubActionPushParamsSchemaITSM = schema.object({ incident: schema.object({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts index fcdd0f31b6ec6..ce6f33e1cc0d1 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts @@ -101,7 +101,10 @@ const mockCorrelationIdIncidentResponse = () => }, })); -const createIncident = async (service: ExternalService) => { +const createIncident = async ( + service: ExternalService, + incident?: Partial +) => { // Get application version mockApplicationVersion(); // Import set api response @@ -110,11 +113,18 @@ const createIncident = async (service: ExternalService) => { mockIncidentResponse(false); return await service.createIncident({ - incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + incident: { + short_description: 'title', + description: 'desc', + ...incident, + } as ServiceNowITSMIncident, }); }; -const updateIncident = async (service: ExternalService) => { +const updateIncident = async ( + service: ExternalService, + incident?: Partial +) => { // Get application version mockApplicationVersion(); // Import set api response @@ -124,7 +134,11 @@ const updateIncident = async (service: ExternalService) => { return await service.updateIncident({ incidentId: '1', - incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + incident: { + short_description: 'title', + description: 'desc', + ...incident, + } as ServiceNowITSMIncident, }); }; @@ -682,6 +696,19 @@ describe('ServiceNow service', () => { '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown' ); }); + + test('it should create an incident with additional fields correctly without prefixing them with u_', async () => { + await createIncident(service, { additional_fields: { foo: 'test' } }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', foo: 'test' }, + }); + }); }); // old connectors @@ -755,6 +782,18 @@ describe('ServiceNow service', () => { expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); + + test('it should throw if tries to update an incident with additional_fields', async () => { + await expect( + service.createIncident({ + incident: { + additional_fields: {}, + } as ServiceNowITSMIncident, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[Action][ServiceNow]: Unable to create incident. Error: ServiceNow additional fields are not supported for deprecated connectors. Reason: unknown: errorResponse was null"` + ); + }); }); }); @@ -860,6 +899,24 @@ describe('ServiceNow service', () => { '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown' ); }); + + test('it should update an incident with additional fields correctly without prefixing them with u_', async () => { + await updateIncident(service, { additional_fields: { foo: 'test' } }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { + u_short_description: 'title', + u_description: 'desc', + elastic_incident_id: '1', + foo: 'test', + }, + }); + }); }); // old connectors @@ -935,6 +992,19 @@ describe('ServiceNow service', () => { expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); }); + + test('it should throw if tries to update an incident with additional_fields', async () => { + await expect( + service.updateIncident({ + incidentId: '1', + incident: { + additional_fields: {}, + } as ServiceNowITSMIncident, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[Action][ServiceNow]: Unable to update incident with id 1. Error: ServiceNow additional fields are not supported for deprecated connectors. Reason: unknown: errorResponse was null"` + ); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts index 906c47c962d82..42aed9dcf8466 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts @@ -22,7 +22,12 @@ import { import * as i18n from './translations'; import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; -import { createServiceError, getPushedDate, prepareIncident } from './utils'; +import { + createServiceError, + getPushedDate, + prepareIncident, + throwIfAdditionalFieldsNotSupported, +} from './utils'; export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; @@ -186,6 +191,7 @@ export const createExternalService: ServiceFactory = ({ const createIncident = async ({ incident }: ExternalServiceParamsCreate) => { try { + throwIfAdditionalFieldsNotSupported(useTableApi, incident); await checkIfApplicationIsInstalled(); const res = await request({ @@ -219,6 +225,7 @@ export const createExternalService: ServiceFactory = ({ const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => { try { + throwIfAdditionalFieldsNotSupported(useTableApi, incident); await checkIfApplicationIsInstalled(); const res = await request({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.test.ts index 8bc9fa0565d8f..5b6bc9864a20f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.test.ts @@ -15,6 +15,7 @@ import { getPushedDate, throwIfSubActionIsNotSupported, getAxiosInstance, + throwIfAdditionalFieldsNotSupported, } from './utils'; import type { ResponseError } from './types'; import { connectorTokenClientMock } from '@kbn/actions-plugin/server/lib/connector_token_client.mock'; @@ -62,6 +63,46 @@ describe('utils', () => { const newIncident = prepareIncident(true, incident); expect(newIncident).toEqual(incident); }); + + test('does not prefix additional fields with u_', async () => { + const incident = { + short_description: 'title', + description: 'desc', + additional_fields: { foo: 'test' }, + }; + + const newIncident = prepareIncident(false, incident); + expect(newIncident).toEqual({ + u_short_description: 'title', + u_description: 'desc', + foo: 'test', + }); + }); + + test('strips out additional fields if it is a deprecated connector', async () => { + const incident = { + short_description: 'title', + description: 'desc', + additional_fields: { foo: 'test' }, + }; + + const newIncident = prepareIncident(true, incident); + expect(newIncident).toEqual({ short_description: 'title', description: 'desc' }); + }); + + test('does not overrides base fields', async () => { + const incident = { + short_description: 'title', + description: 'desc', + additional_fields: { u_short_description: 'foo' }, + }; + + const newIncident = prepareIncident(false, incident); + expect(newIncident).toEqual({ + u_short_description: 'title', + u_description: 'desc', + }); + }); }); describe('createServiceError', () => { @@ -417,4 +458,28 @@ describe('utils', () => { expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled(); }); }); + + describe('throwIfAdditionalFieldsNotSupported', () => { + it('throws if the connector is deprecated and it sets additional_fields', async () => { + expect.assertions(1); + + expect(() => throwIfAdditionalFieldsNotSupported(true, { additional_fields: {} })).toThrow( + 'ServiceNow additional fields are not supported for deprecated connectors.' + ); + }); + + it('does not throw if the connector is deprecated and it does not set additional_fields', async () => { + expect(() => throwIfAdditionalFieldsNotSupported(true, {})).not.toThrow(); + }); + + it('does not throw if the connector is not and it set additional_fields', async () => { + expect(() => + throwIfAdditionalFieldsNotSupported(false, { additional_fields: {} }) + ).not.toThrow(); + }); + + it('does not throw if the connector is not and it does not set additional_fields', async () => { + expect(() => throwIfAdditionalFieldsNotSupported(false, {})).not.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.ts index 8998fa9f94c63..ff0755f8d7499 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.ts @@ -24,13 +24,23 @@ import { import { FIELD_PREFIX } from './config'; 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 - ); +export const prepareIncident = ( + useOldApi: boolean, + incident: PartialIncident +): Record => { + const { additional_fields: additionalFields, ...restIncidentFields } = incident; + + if (useOldApi) { + return restIncidentFields; + } + + const baseFields = Object.entries(restIncidentFields).reduce>( + (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }), + {} + ); + + return { ...additionalFields, ...baseFields }; +}; const createErrorMessage = (errorResponse?: ServiceNowError): string => { if (errorResponse == null) { @@ -91,6 +101,18 @@ export const throwIfSubActionIsNotSupported = ({ } }; +export const throwIfAdditionalFieldsNotSupported = ( + useOldApi: boolean, + incident: PartialIncident +) => { + if (useOldApi && incident.additional_fields) { + throw new AxiosError( + 'ServiceNow additional fields are not supported for deprecated connectors.', + '400' + ); + } +}; + export interface GetAxiosInstanceOpts { connectorId: string; logger: Logger; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.test.ts index 598f5ba576d0f..1174081f1adeb 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { validateCommonConfig, validateCommonSecrets, validateCommonConnector } from './validators'; +import { + validateCommonConfig, + validateCommonSecrets, + validateCommonConnector, + validateOtherFieldsKeys, +} from './validators'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; const configurationUtilities = actionsConfigMock.create(); @@ -430,4 +435,12 @@ describe('validateCommonConnector', () => { ); }); }); + + describe('validateOtherFieldsKeys', () => { + it('returns an error if the keys are not allowed', () => { + expect(validateOtherFieldsKeys('short_description')).toEqual( + 'The following properties cannot be defined inside additional_fields: short_description.' + ); + }); + }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.ts index 1ff9d09ad54ee..e30715c8bce93 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.ts @@ -13,6 +13,8 @@ import { } from './types'; import * as i18n from './translations'; +import { validateKeysAllowed } from '../validators'; +import { commonIncidentSchemaObjectProperties } from './schema'; export const validateCommonConfig = ( config: ServiceNowPublicConfigurationType, @@ -120,3 +122,11 @@ export const validate: ExternalServiceValidation = { secrets: validateCommonSecrets, connector: validateCommonConnector, }; + +export const validateOtherFieldsKeys = (key: string): string | undefined => { + return validateKeysAllowed({ + key, + disallowList: commonIncidentSchemaObjectProperties, + fieldName: 'additional_fields', + }); +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/validators.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/validators.test.ts new file mode 100644 index 0000000000000..3397c24bee8b5 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/validators.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { validateKeysAllowed, validateRecordMaxKeys } from './validators'; + +describe('validators', () => { + describe('validateRecordMaxKeys', () => { + it('returns undefined if the keys of the record are less than the maximum', () => { + expect( + validateRecordMaxKeys({ + record: { foo: 'bar' }, + maxNumberOfFields: 2, + fieldName: 'myFieldName', + }) + ).toBeUndefined(); + }); + + it('returns an error if the keys of the record are greater than the maximum', () => { + expect( + validateRecordMaxKeys({ + record: { foo: 'bar', bar: 'test', test: 'foo' }, + maxNumberOfFields: 2, + fieldName: 'myFieldName', + }) + ).toEqual('A maximum of 2 fields in myFieldName can be defined at a time.'); + }); + }); + + describe('validateKeysAllowed', () => { + it('returns undefined if the keys are allowed', () => { + expect( + validateKeysAllowed({ + key: 'foo', + disallowList: ['bar'], + fieldName: 'myFieldName', + }) + ).toBeUndefined(); + }); + + it('returns an error if the keys are not allowed', () => { + expect( + validateKeysAllowed({ + key: 'foo', + disallowList: ['foo'], + fieldName: 'myFieldName', + }) + ).toEqual('The following properties cannot be defined inside myFieldName: foo.'); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/lib/validators.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/validators.ts new file mode 100644 index 0000000000000..262487d857c26 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/lib/validators.ts @@ -0,0 +1,51 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const FIELDS_MAX_LENGTH_ERROR = (length: number, fieldName: string) => + i18n.translate('xpack.stackConnectors.schema.otherFieldsLengthError', { + values: { length, fieldName }, + defaultMessage: + 'A maximum of {length, plural, =1 {{length} field} other {{length} fields}} in {fieldName} can be defined at a time.', + }); + +export const FIELDS_KEY_NOT_ALLOWED_ERROR = (properties: string, fieldName: string) => + i18n.translate('xpack.stackConnectors.schema.otherFieldsPropertyError', { + values: { properties, fieldName }, + defaultMessage: 'The following properties cannot be defined inside {fieldName}: {properties}.', + }); + +export const validateRecordMaxKeys = ({ + record, + maxNumberOfFields, + fieldName, +}: { + record: Record; + maxNumberOfFields: number; + fieldName: string; +}): string | undefined => { + if (Object.keys(record).length > maxNumberOfFields) { + return FIELDS_MAX_LENGTH_ERROR(maxNumberOfFields, fieldName); + } +}; + +export const validateKeysAllowed = ({ + key, + disallowList, + fieldName, +}: { + key: string; + disallowList: string[]; + fieldName: string; +}): string | undefined => { + const propertiesSet = new Set(disallowList); + + if (propertiesSet.has(key)) { + return FIELDS_KEY_NOT_ALLOWED_ERROR(key, fieldName); + } +}; diff --git a/x-pack/plugins/stack_connectors/tsconfig.json b/x-pack/plugins/stack_connectors/tsconfig.json index 045d5e54b461b..7a6898a5ce829 100644 --- a/x-pack/plugins/stack_connectors/tsconfig.json +++ b/x-pack/plugins/stack_connectors/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/cases-components", "@kbn/code-editor-mock", "@kbn/utility-types", + "@kbn/alerting-types", ], "exclude": [ "target/**/*", diff --git a/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts b/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts index 15113c2c1cd08..864d73a991e50 100644 --- a/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts +++ b/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts @@ -262,6 +262,7 @@ const statusRule = { externalId: null, correlation_id: null, correlation_display: null, + additional_fields: null, }, comments: [], }, @@ -464,6 +465,7 @@ const tlsRule = { externalId: null, correlation_id: null, correlation_display: null, + additional_fields: null, }, comments: [], }, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts index e291f11250df8..054678996888f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/jira.ts @@ -426,7 +426,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { status: 'error', retry: false, message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.otherFields]: types that failed validation:\n - [subActionParams.incident.otherFields.0]: A maximum of 20 otherFields can be defined at a time.\n - [subActionParams.incident.otherFields.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', + '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.otherFields]: types that failed validation:\n - [subActionParams.incident.otherFields.0]: A maximum of 20 fields in otherFields can be defined at a time.\n - [subActionParams.incident.otherFields.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [issueTypes]\n- [5.subAction]: expected value to equal [fieldsByIssueType]\n- [6.subAction]: expected value to equal [issues]\n- [7.subAction]: expected value to equal [issue]', errorSource: TaskErrorSource.FRAMEWORK, }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts index 3241f9b80ab1f..ec62e6d30f6ff 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_itsm.ts @@ -14,6 +14,7 @@ import http from 'http'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '@kbn/stack-connectors-plugin/common/servicenow/constants'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -578,6 +579,81 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) { }); }); + it('throws when trying to create an incident with too many "additional_fields"', async () => { + const additionalFields = new Array(MAX_ADDITIONAL_FIELDS_LENGTH + 1) + .fill('foobar') + .reduce((acc, curr, idx) => { + acc[idx] = curr; + return acc; + }, {}); + + const res = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNowBasic.params, + subActionParams: { + ...mockServiceNowBasic.params.subActionParams, + incident: { + ...mockServiceNowBasic.params.subActionParams.incident, + additional_fields: additionalFields, + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(res.body.status).to.eql('error'); + }); + + it('throws when trying to create an incident with "additional_fields" keys that are not allowed', async () => { + const res = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNowBasic.params, + subActionParams: { + ...mockServiceNowBasic.params.subActionParams, + incident: { + ...mockServiceNowBasic.params.subActionParams.incident, + additional_fields: { + short_description: 'foo', + }, + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(res.body.status).to.eql('error'); + }); + + it('does not throw when "additional_fields" is a valid JSON object send as string', async () => { + const res = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNowBasic.params, + subActionParams: { + ...mockServiceNowBasic.params.subActionParams, + incident: { + ...mockServiceNowBasic.params.subActionParams.incident, + otherFields: '{ "foo": "bar" }', + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(res.body.status).to.eql('error'); + }); + describe('getChoices', () => { it('should fail when field is not provided', async () => { await supertest diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts index 6ab9a257c9261..fd4781f6d9e62 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/servicenow_sir.ts @@ -14,6 +14,7 @@ import http from 'http'; import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { MAX_ADDITIONAL_FIELDS_LENGTH } from '@kbn/stack-connectors-plugin/common/servicenow/constants'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -591,6 +592,81 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) { }); }); + it('throws when trying to create an incident with too many "additional_fields"', async () => { + const additionalFields = new Array(MAX_ADDITIONAL_FIELDS_LENGTH + 1) + .fill('foobar') + .reduce((acc, curr, idx) => { + acc[idx] = curr; + return acc; + }, {}); + + const res = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNowBasic.params, + subActionParams: { + ...mockServiceNowBasic.params.subActionParams, + incident: { + ...mockServiceNowBasic.params.subActionParams.incident, + additional_fields: additionalFields, + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(res.body.status).to.eql('error'); + }); + + it('throws when trying to create an incident with "additional_fields" keys that are not allowed', async () => { + const res = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNowBasic.params, + subActionParams: { + ...mockServiceNowBasic.params.subActionParams, + incident: { + ...mockServiceNowBasic.params.subActionParams.incident, + additional_fields: { + short_description: 'foo', + }, + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(res.body.status).to.eql('error'); + }); + + it('does not throw when "additional_fields" is a valid JSON object send as string', async () => { + const res = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockServiceNowBasic.params, + subActionParams: { + ...mockServiceNowBasic.params.subActionParams, + incident: { + ...mockServiceNowBasic.params.subActionParams.incident, + otherFields: '{ "foo": "bar" }', + }, + comments: [], + }, + }, + }) + .expect(200); + + expect(res.body.status).to.eql('error'); + }); + describe('getChoices', () => { it('should fail when field is not provided', async () => { await supertest