Skip to content

Commit

Permalink
[Actions][ServiceNow] Allow to close serviceNow incident when alert r…
Browse files Browse the repository at this point in the history
…esolves (#171760)

## Summary

Fixes: #170522

This PR allows to `close service now incident` when alert is `recovered`

SN connector form shows only `correlation_id` field as it is mandatory
field to close an incident.

![Screenshot 2023-11-27 at 11 52
36](https://github.com/elastic/kibana/assets/117571355/1d722153-f77a-484a-b17b-13489f9d7666)

**How to test:**
- Create a rule and select serviceNow ITSM action with Run when option
as Recovered
- Verify that it closes an incident in SN when Alert is recovered


### 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)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
js-jankisalvi and kibanamachine committed Dec 1, 2023
1 parent b06980c commit d31a158
Show file tree
Hide file tree
Showing 22 changed files with 1,105 additions and 205 deletions.
2 changes: 1 addition & 1 deletion x-pack/plugins/alerting/common/disabled_action_groups.ts
Expand Up @@ -8,7 +8,7 @@
import { RecoveredActionGroup } from './builtin_action_groups';

const DisabledActionGroupsByActionType: Record<string, string[]> = {
[RecoveredActionGroup.id]: ['.jira', '.servicenow', '.resilient'],
[RecoveredActionGroup.id]: ['.jira', '.resilient'],
};

export const DisabledActionTypeIdsForActionGroup: Map<string, string[]> = new Map(
Expand Down
Expand Up @@ -16,6 +16,8 @@ import { AppInfo, Choice, RESTApiError } from './types';

export const DEFAULT_CORRELATION_ID = '{{rule.id}}:{{alert.id}}';

export const ACTION_GROUP_RECOVERED = 'recovered';

export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));

Expand Down
Expand Up @@ -56,6 +56,13 @@ export const TITLE_REQUIRED = i18n.translate(
}
);

export const CORRELATION_ID_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.serviceNow.requiredCorrelationIdTextField',
{
defaultMessage: 'Correlation id is required.',
}
);

export const INCIDENT = i18n.translate('xpack.stackConnectors.components.serviceNow.title', {
defaultMessage: 'Incident',
});
Expand Down
Expand Up @@ -35,7 +35,25 @@ describe('servicenow action params validation', () => {
};

expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: { ['subActionParams.incident.short_description']: [] },
errors: {
['subActionParams.incident.correlation_id']: [],
['subActionParams.incident.short_description']: [],
},
});
});

test(`${SERVICENOW_ITSM_CONNECTOR_TYPE_ID}: action params validation succeeds for closeIncident subAction`, async () => {
const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID);
const actionParams = {
subAction: 'closeIncident',
subActionParams: { incident: { correlation_id: '{{test}}{{rule_id}}' } },
};

expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
['subActionParams.incident.correlation_id']: [],
['subActionParams.incident.short_description']: [],
},
});
});

Expand All @@ -47,8 +65,24 @@ describe('servicenow action params validation', () => {

expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
['subActionParams.incident.correlation_id']: [],
['subActionParams.incident.short_description']: ['Short description is required.'],
},
});
});

test(`${SERVICENOW_ITSM_CONNECTOR_TYPE_ID}: params validation fails when correlation_id is not valid and subAction is closeIncident`, async () => {
const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID);
const actionParams = {
subAction: 'closeIncident',
subActionParams: { incident: { correlation_id: '' } },
};

expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
['subActionParams.incident.correlation_id']: ['Correlation id is required.'],
['subActionParams.incident.short_description']: [],
},
});
});
});
Expand Up @@ -13,7 +13,11 @@ import type {
} from '@kbn/triggers-actions-ui-plugin/public';
import { ServiceNowConfig, ServiceNowSecrets } from '../lib/servicenow/types';
import { ServiceNowITSMActionParams } from './types';
import { getConnectorDescriptiveTitle, getSelectedConnectorIcon } from '../lib/servicenow/helpers';
import {
DEFAULT_CORRELATION_ID,
getConnectorDescriptiveTitle,
getSelectedConnectorIcon,
} from '../lib/servicenow/helpers';

export const SERVICENOW_ITSM_DESC = i18n.translate(
'xpack.stackConnectors.components.serviceNowITSM.selectMessageText',
Expand Down Expand Up @@ -46,23 +50,47 @@ export function getServiceNowITSMConnectorType(): ConnectorTypeModel<
const translations = await import('../lib/servicenow/translations');
const errors = {
'subActionParams.incident.short_description': new Array<string>(),
'subActionParams.incident.correlation_id': new Array<string>(),
};
const validationResult = {
errors,
};
if (
actionParams.subActionParams &&
actionParams.subActionParams.incident &&
actionParams.subAction !== 'closeIncident' &&
!actionParams.subActionParams.incident.short_description?.length
) {
errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED);
}

if (
actionParams.subAction === 'closeIncident' &&
!actionParams?.subActionParams?.incident?.correlation_id?.length
) {
errors['subActionParams.incident.correlation_id'].push(
translations.CORRELATION_ID_REQUIRED
);
}
return validationResult;
},
actionParamsFields: lazy(() => import('./servicenow_itsm_params')),
customConnectorSelectItem: {
getText: getConnectorDescriptiveTitle,
getComponent: getSelectedConnectorIcon,
},
defaultActionParams: {
subAction: 'pushToService',
subActionParams: {
incident: { correlation_id: DEFAULT_CORRELATION_ID },
comments: [],
},
},
defaultRecoveredActionParams: {
subAction: 'closeIncident',
subActionParams: {
incident: { correlation_id: DEFAULT_CORRELATION_ID },
},
},
};
}
Expand Up @@ -14,6 +14,7 @@ import { useGetChoices } from '../lib/servicenow/use_get_choices';
import ServiceNowITSMParamsFields from './servicenow_itsm_params';
import { Choice } from '../lib/servicenow/types';
import { merge } from 'lodash';
import { ACTION_GROUP_RECOVERED } from '../lib/servicenow/helpers';

jest.mock('../lib/servicenow/use_get_choices');
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
Expand Down Expand Up @@ -151,14 +152,11 @@ describe('ServiceNowITSMParamsFields renders', () => {
expect(title.prop('isInvalid')).toBeTruthy();
});

test('When subActionParams is undefined, set to default', () => {
const { subActionParams, ...newParams } = actionParams;

const newProps = {
...defaultProps,
actionParams: newParams,
};
mountWithIntl(<ServiceNowITSMParamsFields {...newProps} />);
test('Resets fields when connector changes', () => {
const wrapper = mountWithIntl(<ServiceNowITSMParamsFields {...defaultProps} />);
expect(editAction.mock.calls.length).toEqual(0);
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction.mock.calls.length).toEqual(1);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {
correlation_id: '{{rule.id}}:{{alert.id}}',
Expand All @@ -167,27 +165,17 @@ describe('ServiceNowITSMParamsFields renders', () => {
});
});

test('When subAction is undefined, set to default', () => {
const { subAction, ...newParams } = actionParams;

test('Resets fields when connector changes and action group is recovered', () => {
const newProps = {
...defaultProps,
actionParams: newParams,
selectedActionGroupId: ACTION_GROUP_RECOVERED,
};
mountWithIntl(<ServiceNowITSMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual('pushToService');
});

test('Resets fields when connector changes', () => {
const wrapper = mountWithIntl(<ServiceNowITSMParamsFields {...defaultProps} />);
const wrapper = mountWithIntl(<ServiceNowITSMParamsFields {...newProps} />);
expect(editAction.mock.calls.length).toEqual(0);
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction.mock.calls.length).toEqual(1);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {
correlation_id: '{{rule.id}}:{{alert.id}}',
},
comments: [],
incident: { correlation_id: '{{rule.id}}:{{alert.id}}' },
});
});

Expand Down Expand Up @@ -299,5 +287,57 @@ describe('ServiceNowITSMParamsFields renders', () => {
expect(comments.simulate('change', changeEvent));
expect(editAction.mock.calls[0][1].comments.length).toEqual(1);
});

test('shows only correlation_id field when actionGroup is recovered', () => {
const newProps = {
...defaultProps,
selectedActionGroupId: 'recovered',
};
const wrapper = mountWithIntl(<ServiceNowITSMParamsFields {...newProps} />);
expect(wrapper.find('input[data-test-subj="correlation_idInput"]').exists()).toBeTruthy();
expect(wrapper.find('input[data-test-subj="short_descriptionInput"]').exists()).toBeFalsy();
});

test('A short description change triggers editAction', () => {
const wrapper = mountWithIntl(
<ServiceNowITSMParamsFields
actionParams={{}}
errors={{ ['subActionParams.incident.short_description']: [] }}
editAction={editAction}
index={0}
/>
);

const shortDescriptionField = wrapper.find('input[data-test-subj="short_descriptionInput"]');
shortDescriptionField.simulate('change', {
target: { value: 'new updated short description' },
});

expect(editAction.mock.calls[0][1]).toEqual({
incident: { short_description: 'new updated short description' },
comments: [],
});
});

test('A correlation_id field change triggers edit action correctly when actionGroup is recovered', () => {
const wrapper = mountWithIntl(
<ServiceNowITSMParamsFields
selectedActionGroupId={'recovered'}
actionParams={{}}
errors={{ ['subActionParams.incident.short_description']: [] }}
editAction={editAction}
index={0}
/>
);
const correlationIdField = wrapper.find('input[data-test-subj="correlation_idInput"]');

correlationIdField.simulate('change', {
target: { value: 'updated correlation id' },
});

expect(editAction.mock.calls[0][1]).toEqual({
incident: { correlation_id: 'updated correlation id' },
});
});
});
});

0 comments on commit d31a158

Please sign in to comment.