[ResponseOps][Connectors] Add support of additional fields for ServiceNow ITSM and SecOps (#184023)

## Summary

This PR adds support for additional fields for the ServiceNow ITSM and
SecOps connector. The additional fields will not be available to the
recovered action.

<img width="607" alt="Screenshot 2024-05-27 at 6 29 26 PM"
src="7d397d7b-2b0b-4399-8d3a-0725ad04a10d">

## Testing

Verify that:

1. Existing rules with ITSM and SecOps configured continue working as
expected.
2. Can create rules with an ITSM action and set some additional fields
supported by ITSM. You can find the available in the Elastic
transformation map inside ServiceNow.
3. The "additional fields" verification in the UI is working as
expected.
4. The "additional fields" are not shown when you set a recovered
action.

Fixes: https://github.com/elastic/kibana/issues/183609

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

## Release notes

Pass any field to ServiceNow using the ServiceNow ITSM and SecOps
connectors with a JSON field called "additional fields".

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2024-06-13 13:02:58 +03:00 committed by GitHub
parent ad646cae46
commit 75f3af5711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1174 additions and 52 deletions

View file

@ -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,

View file

@ -222,6 +222,7 @@ function getServiceNowActionParams({ defaultActionMessage }: Translations): Serv
externalId: null,
correlation_id: null,
correlation_display: null,
additional_fields: null,
},
comments: [],
},

View file

@ -214,6 +214,7 @@ function getServiceNowActionParams({ defaultActionMessage }: Translations): Serv
externalId: null,
correlation_id: null,
correlation_display: null,
additional_fields: null,
},
comments: [],
},

View file

@ -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;

View file

@ -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.'],
},
});
});

View file

@ -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<JiraConfig, JiraSecrets,
errors['subActionParams.incident.labels'].push(translations.LABELS_WHITE_SPACES);
}
try {
const otherFields = actionParams.subActionParams?.incident?.otherFields;
if (otherFields) {
const parsedOtherFields = JSON.parse(otherFields);
if (Object.keys(parsedOtherFields).length > 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')),

View file

@ -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(
<IntlProvider locale="en">
<AdditionalFields {...props} />
</IntlProvider>
);
expect(await screen.findByTestId('additionalFields')).toBeInTheDocument();
});
it('sets the value correctly', async () => {
render(
<IntlProvider locale="en">
<AdditionalFields {...props} />
</IntlProvider>
);
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(
<IntlProvider locale="en">
<AdditionalFields {...props} value={undefined} />
</IntlProvider>
);
expect(screen.queryByTestId('additional_fieldsJsonEditor')).not.toBeInTheDocument();
});
it('changes the value correctly', async () => {
const newValue = JSON.stringify({ bar: 'test' });
render(
<IntlProvider locale="en">
<AdditionalFields {...props} />
</IntlProvider>
);
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(
<IntlProvider locale="en">
<AdditionalFields {...props} />
</IntlProvider>
);
const editor = await screen.findByTestId('additional_fieldsJsonEditor');
userEvent.paste(editor, newValue);
userEvent.clear(editor);
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(null);
});
});
});

View file

@ -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<AdditionalFieldsProps> = ({
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 (
<JsonEditorWithMessageVariables
messageVariables={messageVariables}
paramsProperty={'additional_fields'}
inputTargetValue={value}
errors={errors ?? []}
dataTestSubj="additionalFields"
label={
<>
{i18n.ADDITIONAL_FIELDS}
<EuiIconTip
size="s"
color="subdued"
type="questionInCircle"
className="eui-alignTop"
data-test-subj="otherFieldsHelpTooltip"
aria-label={i18n.ADDITIONAL_FIELDS_HELP}
content={i18n.ADDITIONAL_FIELDS_HELP_TEXT}
/>
</>
}
onDocumentsChange={(json: string) => onChange(isEmpty(json) ? null : json)}
/>
);
};
export const AdditionalFields = React.memo(AdditionalFieldsComponent);

View file

@ -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.',
}
);

View file

@ -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();
});
});

View file

@ -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<string, unknown>;
maxProperties?: number;
}
const isObject = (value?: ValidateJSONArgs['value']): value is Record<string, unknown> => {
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;
}
};

View file

@ -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(() =>

View file

@ -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<ConnectorTypeModel>;
@ -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.`,
],
},
});
});

View file

@ -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<string>(),
'subActionParams.incident.correlation_id': new Array<string>(),
'subActionParams.incident.additional_fields': new Array<string>(),
};
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')),

View file

@ -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(<ServiceNowITSMParamsFields {...defaultProps} />, {
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
});
userEvent.paste(await screen.findByTestId('additional_fieldsJsonEditor'), newValue);
await waitFor(() => {
expect(editAction.mock.calls[0][1].incident.additional_fields).toEqual(newValue);
});
});
});
describe('Test form', () => {

View file

@ -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 && (
<AdditionalFields
value={actionParams.subActionParams?.incident.additional_fields}
messageVariables={messageVariables}
errors={errors['subActionParams.incident.additional_fields'] as string[]}
onChange={additionalFieldsOnChange}
/>
)}
</>
)}
{showOnlyCorrelationId && (

View file

@ -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 };
};
}

View file

@ -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<ConnectorTypeModel>;
@ -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.`,
],
},
});
});

View file

@ -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<string>(),
'subActionParams.incident.additional_fields': new Array<string>(),
};
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')),

View file

@ -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(<ServiceNowSIRParamsFields {...defaultProps} />, {
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
});
userEvent.paste(await screen.findByTestId('additional_fieldsJsonEditor'), newValue);
await waitFor(() => {
expect(editAction.mock.calls[0][1].incident.additional_fields).toEqual(newValue);
});
});
});
});

View file

@ -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 && <DeprecatedCallout />}
@ -288,6 +294,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
label={i18n.COMMENTS_LABEL}
/>
<EuiSpacer size="m" />
{!isDeprecatedActionConnector && (
<AdditionalFields
value={actionParams.subActionParams?.incident.additional_fields}
messageVariables={messageVariables}
errors={errors['subActionParams.incident.additional_fields'] as string[]}
onChange={additionalFieldsOnChange}
/>
)}
</>
);
};

View file

@ -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 };
};
}

View file

@ -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)(

View file

@ -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',
}),
}
)
),

View file

@ -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, unknown>
): 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',
});
};

View file

@ -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',
});

View file

@ -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: [
{

View file

@ -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({

View file

@ -101,7 +101,10 @@ const mockCorrelationIdIncidentResponse = () =>
},
}));
const createIncident = async (service: ExternalService) => {
const createIncident = async (
service: ExternalService,
incident?: Partial<ServiceNowITSMIncident>
) => {
// 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<ServiceNowITSMIncident>
) => {
// 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"`
);
});
});
});

View file

@ -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({

View file

@ -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();
});
});
});

View file

@ -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<string, unknown> => {
const { additional_fields: additionalFields, ...restIncidentFields } = incident;
if (useOldApi) {
return restIncidentFields;
}
const baseFields = Object.entries(restIncidentFields).reduce<Partial<Incident>>(
(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;

View file

@ -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.'
);
});
});
});

View file

@ -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',
});
};

View file

@ -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.');
});
});
});

View file

@ -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<string, unknown>;
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);
}
};

View file

@ -40,6 +40,7 @@
"@kbn/cases-components",
"@kbn/code-editor-mock",
"@kbn/utility-types",
"@kbn/alerting-types",
],
"exclude": [
"target/**/*",

View file

@ -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: [],
},

View file

@ -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,
});
});

View file

@ -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

View file

@ -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