[Actions][ServiceNow] Allow to close serviceNow incident when alert resolves (#171760)

## Summary

Fixes: https://github.com/elastic/kibana/issues/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](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>
This commit is contained in:
Janki Salvi 2023-12-01 16:56:37 +01:00 committed by GitHub
parent b06980c8d8
commit d31a15807b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1120 additions and 220 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
@ -46,6 +50,7 @@ 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,
@ -53,10 +58,20 @@ export function getServiceNowITSMConnectorType(): ConnectorTypeModel<
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')),
@ -64,5 +79,18 @@ export function getServiceNowITSMConnectorType(): ConnectorTypeModel<
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 },
},
},
};
}

View file

@ -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');
@ -151,33 +152,6 @@ 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} />);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {
correlation_id: '{{rule.id}}:{{alert.id}}',
},
comments: [],
});
});
test('When subAction is undefined, set to default', () => {
const { subAction, ...newParams } = actionParams;
const newProps = {
...defaultProps,
actionParams: newParams,
};
mountWithIntl(<ServiceNowITSMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual('pushToService');
});
test('Resets fields when connector changes', () => {
const wrapper = mountWithIntl(<ServiceNowITSMParamsFields {...defaultProps} />);
expect(editAction.mock.calls.length).toEqual(0);
@ -191,6 +165,20 @@ describe('ServiceNowITSMParamsFields renders', () => {
});
});
test('Resets fields when connector changes and action group is recovered', () => {
const newProps = {
...defaultProps,
selectedActionGroupId: ACTION_GROUP_RECOVERED,
};
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}}' },
});
});
test('it transforms the categories to options correctly', async () => {
const wrapper = mountWithIntl(<ServiceNowITSMParamsFields {...defaultProps} />);
act(() => {
@ -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' },
});
});
});
});

View file

@ -25,7 +25,11 @@ import {
import { Choice, Fields } from '../lib/servicenow/types';
import { ServiceNowITSMActionParams } from './types';
import { useGetChoices } from '../lib/servicenow/use_get_choices';
import { choicesToEuiOptions, DEFAULT_CORRELATION_ID } from '../lib/servicenow/helpers';
import {
ACTION_GROUP_RECOVERED,
choicesToEuiOptions,
DEFAULT_CORRELATION_ID,
} from '../lib/servicenow/helpers';
import * as i18n from '../lib/servicenow/translations';
@ -39,11 +43,50 @@ const defaultFields: Fields = {
priority: [],
};
const CorrelationIdField: React.FunctionComponent<
Pick<ActionParamsProps<ServiceNowITSMActionParams>, 'index' | 'messageVariables'> & {
correlationId: string | null;
editSubActionProperty: (key: string, value: any) => void;
}
> = ({ index, messageVariables, correlationId, editSubActionProperty }) => {
const { docLinks } = useKibana().services;
return (
<EuiFormRow
fullWidth
label={i18n.CORRELATION_ID}
helpText={
<EuiLink href={docLinks.links.alerting.serviceNowAction} target="_blank">
<FormattedMessage
id="xpack.stackConnectors.components.serviceNow.correlationIDHelpLabel"
defaultMessage="Identifier for updating incidents"
/>
</EuiLink>
}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'correlation_id'}
inputTargetValue={correlationId ?? undefined}
/>
</EuiFormRow>
);
};
const ServiceNowParamsFields: React.FunctionComponent<
ActionParamsProps<ServiceNowITSMActionParams>
> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => {
> = (props) => {
const {
actionConnector,
actionParams,
editAction,
index,
errors,
messageVariables,
selectedActionGroupId,
} = props;
const {
docLinks,
http,
notifications: { toasts },
} = useKibana().services;
@ -56,9 +99,9 @@ const ServiceNowParamsFields: React.FunctionComponent<
actionParams.subActionParams ??
({
incident: {},
comments: [],
comments: selectedActionGroupId !== ACTION_GROUP_RECOVERED ? [] : undefined,
} as unknown as ServiceNowITSMActionParams['subActionParams']),
[actionParams.subActionParams]
[actionParams.subActionParams, selectedActionGroupId]
);
const [choices, setChoices] = useState<Fields>(defaultFields);
@ -122,6 +165,16 @@ const ServiceNowParamsFields: React.FunctionComponent<
useEffect(() => {
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
actionConnectorRef.current = actionConnector.id;
if (selectedActionGroupId === ACTION_GROUP_RECOVERED) {
editAction(
'subActionParams',
{ incident: { correlation_id: DEFAULT_CORRELATION_ID } },
index
);
return;
}
editAction(
'subActionParams',
{
@ -134,196 +187,176 @@ const ServiceNowParamsFields: React.FunctionComponent<
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!actionParams.subActionParams) {
editAction(
'subActionParams',
{
incident: { correlation_id: DEFAULT_CORRELATION_ID },
comments: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionParams]);
return (
<>
<EuiTitle size="s">
<h3>{i18n.INCIDENT}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.URGENCY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={urgencyOptions}
value={incident.urgency ?? ''}
onChange={(e) => editSubActionProperty('urgency', e.target.value)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SEVERITY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={severityOptions}
value={incident.severity ?? ''}
onChange={(e) => editSubActionProperty('severity', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.IMPACT_LABEL}>
<EuiSelect
fullWidth
data-test-subj="impactSelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={impactOptions}
value={incident.impact ?? ''}
onChange={(e) => editSubActionProperty('impact', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={categoryOptions}
value={incident.category ?? undefined}
onChange={(e) => {
editAction(
'subActionParams',
{
incident: { ...incident, category: e.target.value, subcategory: null },
comments,
},
index
);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
{subcategoryOptions?.length > 0 ? (
<EuiFormRow fullWidth label={i18n.SUBCATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={incident.subcategory ?? ''}
onChange={(e) => editSubActionProperty('subcategory', e.target.value)}
/>
</EuiFormRow>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{!isDeprecatedActionConnector && (
{selectedActionGroupId !== ACTION_GROUP_RECOVERED ? (
<>
<EuiFormRow fullWidth label={i18n.URGENCY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={urgencyOptions}
value={incident.urgency ?? ''}
onChange={(e) => editSubActionProperty('urgency', e.target.value)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.CORRELATION_ID}
helpText={
<EuiLink href={docLinks.links.alerting.serviceNowAction} target="_blank">
<FormattedMessage
id="xpack.stackConnectors.components.serviceNow.correlationIDHelpLabel"
defaultMessage="Identifier for updating incidents"
/>
</EuiLink>
}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'correlation_id'}
inputTargetValue={incident?.correlation_id ?? undefined}
<EuiFormRow fullWidth label={i18n.SEVERITY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={severityOptions}
value={incident.severity ?? ''}
onChange={(e) => editSubActionProperty('severity', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CORRELATION_DISPLAY}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'correlation_display'}
inputTargetValue={incident?.correlation_display ?? undefined}
<EuiFormRow fullWidth label={i18n.IMPACT_LABEL}>
<EuiSelect
fullWidth
data-test-subj="impactSelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={impactOptions}
value={incident.impact ?? ''}
onChange={(e) => editSubActionProperty('impact', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={categoryOptions}
value={incident.category ?? undefined}
onChange={(e) => {
editAction(
'subActionParams',
{
incident: { ...incident, category: e.target.value, subcategory: null },
comments,
},
index
);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
{subcategoryOptions?.length > 0 ? (
<EuiFormRow fullWidth label={i18n.SUBCATEGORY_LABEL}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={incident.subcategory ?? ''}
onChange={(e) => editSubActionProperty('subcategory', e.target.value)}
/>
</EuiFormRow>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{!isDeprecatedActionConnector && (
<>
<EuiFlexGroup>
<EuiFlexItem>
<CorrelationIdField
index={index}
messageVariables={messageVariables}
correlationId={incident.correlation_id}
editSubActionProperty={editSubActionProperty}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CORRELATION_DISPLAY}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'correlation_display'}
inputTargetValue={incident?.correlation_display ?? undefined}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'] !== undefined &&
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.SHORT_DESCRIPTION_LABEL}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={incident.description ?? undefined}
label={i18n.DESCRIPTION_LABEL}
/>
<TextAreaWithMessageVariables
index={index}
editAction={editComment}
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.COMMENTS_LABEL}
/>
</>
) : (
<CorrelationIdField
index={index}
messageVariables={messageVariables}
correlationId={incident.correlation_id}
editSubActionProperty={editSubActionProperty}
/>
)}
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'] !== undefined &&
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.SHORT_DESCRIPTION_LABEL}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={incident.description ?? undefined}
label={i18n.DESCRIPTION_LABEL}
/>
<TextAreaWithMessageVariables
index={index}
editAction={editComment}
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.COMMENTS_LABEL}
/>
</>
);
};

View file

@ -356,6 +356,84 @@ describe('api', () => {
});
});
describe('close incident', () => {
test('it closes an incident with incidentId', async () => {
const res = await api.closeIncident({
externalService,
params: {
incident: {
externalId: apiParams.incident.externalId,
correlation_id: null,
},
},
logger: mockedLogger,
});
expect(res).toEqual({
id: 'incident-2',
title: 'INC02',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
test('it closes an incident with correlation_id', async () => {
const res = await api.closeIncident({
externalService,
params: {
incident: {
externalId: null,
correlation_id: apiParams.incident.correlation_id,
},
},
logger: mockedLogger,
});
expect(res).toEqual({
id: 'incident-2',
title: 'INC02',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
test('it calls closeIncident correctly', async () => {
await api.closeIncident({
externalService,
params: {
incident: {
externalId: apiParams.incident.externalId,
correlation_id: null,
},
},
logger: mockedLogger,
});
expect(externalService.closeIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
correlationId: null,
});
});
test('it calls closeIncident correctly with correlation_id', async () => {
await api.closeIncident({
externalService,
params: {
incident: {
externalId: null,
correlation_id: apiParams.incident.correlation_id,
},
},
logger: mockedLogger,
});
expect(externalService.closeIncident).toHaveBeenCalledWith({
incidentId: null,
correlationId: 'ruleId',
});
});
});
describe('getFields', () => {
test('it returns the fields correctly', async () => {
const res = await api.getFields({

View file

@ -16,6 +16,8 @@ import {
Incident,
PushToServiceApiHandlerArgs,
PushToServiceResponse,
CloseIncidentApiHandlerArgs,
ExternalServiceIncidentResponse,
} from './types';
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
@ -74,6 +76,20 @@ const pushToServiceHandler = async ({
return res;
};
const closeIncidentHandler = async ({
externalService,
params,
}: CloseIncidentApiHandlerArgs): Promise<ExternalServiceIncidentResponse | null> => {
const { externalId, correlation_id: correlationId } = params.incident;
const res = await externalService.closeIncident({
correlationId,
incidentId: externalId,
});
return res;
};
const getFieldsHandler = async ({
externalService,
}: GetCommonFieldsHandlerArgs): Promise<GetCommonFieldsResponse> => {
@ -95,4 +111,5 @@ export const api: ExternalServiceAPI = {
getIncident: getIncidentHandler,
handshake: handshakeHandler,
pushToService: pushToServiceHandler,
closeIncident: closeIncidentHandler,
};

View file

@ -91,6 +91,16 @@ const createMock = (): jest.Mocked<ExternalService> => {
description: 'description from servicenow',
})
),
getIncidentByCorrelationId: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-1',
title: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
short_description: 'title from servicenow',
description: 'description from servicenow',
})
),
createIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-1',
@ -107,6 +117,14 @@ const createMock = (): jest.Mocked<ExternalService> => {
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
})
),
closeIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-2',
title: 'INC02',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
})
),
findIncidents: jest.fn(),
getApplicationInformation: jest.fn().mockImplementation(() =>
Promise.resolve({

View file

@ -115,6 +115,13 @@ export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
externalId: schema.string(),
});
export const ExecutorSubActionCloseIncidentParamsSchema = schema.object({
incident: schema.object({
externalId: schema.nullable(schema.string()),
correlation_id: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })),
}),
});
// Reserved for future implementation
export const ExecutorSubActionHandshakeParamsSchema = schema.object({});
export const ExecutorSubActionCommonFieldsParamsSchema = schema.object({});
@ -144,6 +151,10 @@ export const ExecutorParamsSchemaITSM = schema.oneOf([
subAction: schema.literal('getChoices'),
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
}),
schema.object({
subAction: schema.literal('closeIncident'),
subActionParams: ExecutorSubActionCloseIncidentParamsSchema,
}),
]);
// Executor parameters for ServiceNow Security Incident Response (SIR)

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import axios, { AxiosResponse } from 'axios';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { createExternalService } from './service';
import * as utils from '@kbn/actions-plugin/server/lib/axios_utils';
@ -17,7 +17,10 @@ import { serviceNowCommonFields, serviceNowChoices } from './mocks';
import { snExternalServiceConfig } from './config';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('axios', () => ({
create: jest.fn(),
AxiosError: jest.requireActual('axios').AxiosError,
}));
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {
@ -73,7 +76,7 @@ const mockImportIncident = (update: boolean) =>
}));
const mockIncidentResponse = (update: boolean) =>
requestMock.mockImplementation(() => ({
requestMock.mockImplementationOnce(() => ({
data: {
result: {
sys_id: '1',
@ -85,6 +88,19 @@ const mockIncidentResponse = (update: boolean) =>
},
}));
const mockCorrelationIdIncidentResponse = () =>
requestMock.mockImplementationOnce(() => ({
data: {
result: [
{
sys_id: '1',
number: 'INC01',
sys_updated_on: '2020-03-10 12:24:20',
},
],
},
}));
const createIncident = async (service: ExternalService) => {
// Get application version
mockApplicationVersion();
@ -112,6 +128,35 @@ const updateIncident = async (service: ExternalService) => {
});
};
const closeIncident = async ({
service,
incidentId,
correlationId,
}: {
service: ExternalService;
incidentId: string | null;
correlationId: string | null;
}) => {
// Get incident response
if (incidentId) {
mockIncidentResponse(false);
} else if (correlationId) {
// get incident by correlationId response
mockCorrelationIdIncidentResponse();
}
// Get application version
mockApplicationVersion();
// Import set api response
mockImportIncident(true);
// Get incident response
mockIncidentResponse(true);
return await service.closeIncident({
incidentId: incidentId ?? null,
correlationId: correlationId ?? null,
});
};
const expectImportedIncident = (update: boolean) => {
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
@ -439,7 +484,7 @@ describe('ServiceNow service', () => {
throw new Error('An error has occurred');
});
await expect(service.getIncident('1')).rejects.toThrow(
'Unable to get incident with id 1. Error: An error has occurred'
'[Action][ServiceNow]: Unable to get incident with id 1. Error: An error has occurred Reason: unknown: errorResponse was null'
);
});
@ -455,6 +500,88 @@ describe('ServiceNow service', () => {
});
});
describe('getIncidentByCorrelationId', () => {
test('it returns the incident correctly', async () => {
requestMock.mockImplementation(() => ({
data: { result: [{ sys_id: '1', number: 'INC01' }] },
}));
const res = await service.getIncidentByCorrelationId('custom_correlation_id');
expect(res).toEqual({ sys_id: '1', number: 'INC01' });
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: { result: [{ sys_id: '1', number: 'INC01' }] },
}));
await service.getIncidentByCorrelationId('custom_correlation_id');
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=custom_correlation_id',
method: 'get',
});
});
test('it should return null if response is empty', async () => {
requestMock.mockImplementation(() => ({
data: { result: [] },
}));
const res = await service.getIncidentByCorrelationId('custom_correlation_id');
expect(requestMock).toHaveBeenCalledTimes(1);
expect(res).toBe(null);
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService({
credentials: {
config: { apiUrl: 'https://example.com/', isOAuth: false },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' },
axiosInstance: axios,
});
requestMock.mockImplementation(() => ({
data: { result: [{ sys_id: '1', number: 'INC01' }] },
}));
await service.getIncidentByCorrelationId('custom_correlation_id');
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=custom_correlation_id',
method: 'get',
});
});
test('it should throw an error', async () => {
requestMock.mockImplementationOnce(() => {
throw new Error('An error has occurred');
});
await expect(service.getIncidentByCorrelationId('custom_correlation_id')).rejects.toThrow(
'[Action][ServiceNow]: Unable to get incident by correlation ID custom_correlation_id. Error: An error has occurred Reason: unknown: errorResponse was null'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementationOnce(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
describe('createIncident', () => {
// new connectors
describe('import set table', () => {
@ -574,6 +701,8 @@ describe('ServiceNow service', () => {
test('it creates the incident correctly', async () => {
mockIncidentResponse(false);
mockIncidentResponse(false);
const res = await service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
@ -608,6 +737,7 @@ describe('ServiceNow service', () => {
axiosInstance: axios,
});
mockIncidentResponse(false);
mockIncidentResponse(false);
const res = await service.createIncident({
@ -749,6 +879,8 @@ describe('ServiceNow service', () => {
test('it updates the incident correctly', async () => {
mockIncidentResponse(true);
mockIncidentResponse(true);
const res = await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
@ -785,6 +917,7 @@ describe('ServiceNow service', () => {
});
mockIncidentResponse(false);
mockIncidentResponse(true);
const res = await service.updateIncident({
incidentId: '1',
@ -805,6 +938,311 @@ describe('ServiceNow service', () => {
});
});
describe('closeIncident', () => {
// new connectors
describe('import set table', () => {
test('it closes the incident correctly with incident id', async () => {
const res = await closeIncident({ service, incidentId: '1', correlationId: null });
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
});
test('it should call request with correct arguments with incidentId', async () => {
const res = await closeIncident({ service, incidentId: '1', correlationId: null });
expect(requestMock).toHaveBeenCalledTimes(4);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident/1',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(3, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident',
method: 'post',
data: {
elastic_incident_id: '1',
u_close_code: 'Closed/Resolved by Caller',
u_state: '7',
u_close_notes: 'Closed by Caller',
},
});
expect(requestMock).toHaveBeenNthCalledWith(4, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident/1',
method: 'get',
});
expect(res?.url).toEqual('https://example.com/nav_to.do?uri=incident.do?sys_id=1');
});
test('it closes the incident correctly with correlation id', async () => {
const res = await closeIncident({
service,
incidentId: null,
correlationId: 'custom_correlation_id',
});
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
});
test('it should call request with correct arguments with correlationId', async () => {
const res = await closeIncident({
service,
incidentId: null,
correlationId: 'custom_correlation_id',
});
expect(requestMock).toHaveBeenCalledTimes(4);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=custom_correlation_id',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(3, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident',
method: 'post',
data: {
elastic_incident_id: '1',
u_close_code: 'Closed/Resolved by Caller',
u_state: '7',
u_close_notes: 'Closed by Caller',
},
});
expect(requestMock).toHaveBeenNthCalledWith(4, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident/1',
method: 'get',
});
expect(res?.url).toEqual('https://example.com/nav_to.do?uri=incident.do?sys_id=1');
});
test('it should throw an error when the incidentId and correlation Id are null', async () => {
await expect(
service.closeIncident({ incidentId: null, correlationId: null })
).rejects.toThrow(
'[Action][ServiceNow]: Unable to close incident. Error: No correlationId or incidentId found. Reason: unknown: errorResponse was null'
);
});
test('it should throw an error when the no incidents found with given incidentId ', async () => {
const axiosError = {
message: 'Request failed with status code 404',
response: { status: 404 },
} as AxiosError;
requestMock.mockImplementation(() => {
throw axiosError;
});
const res = await service.closeIncident({
incidentId: 'xyz',
correlationId: null,
});
expect(requestMock).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"[ServiceNow][CloseIncident] No incident found with incidentId: xyz.",
]
`);
expect(res).toBeNull();
});
test('it should log warning if found incident is closed', async () => {
requestMock.mockImplementationOnce(() => ({
data: {
result: {
sys_id: '1',
number: 'INC01',
state: '7',
sys_created_on: '2020-03-10 12:24:20',
},
},
}));
await service.closeIncident({ incidentId: '1', correlationId: null });
expect(requestMock).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"[ServiceNow][CloseIncident] Incident with correlation_id: null or incidentId: 1 is closed.",
]
`);
});
test('it should return null if found incident with correlation id is null', async () => {
requestMock.mockImplementationOnce(() => ({
data: {
result: [],
},
}));
const res = await service.closeIncident({
incidentId: null,
correlationId: 'bar',
});
expect(requestMock).toHaveBeenCalledTimes(1);
expect(logger.warn.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"[ServiceNow][CloseIncident] No incident found with correlation_id: bar or incidentId: null.",
]
`);
expect(res).toBeNull();
});
test('it should throw an error when instance is not alive', async () => {
mockIncidentResponse(false);
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(
service.closeIncident({
incidentId: '1',
correlationId: null,
})
).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
// old connectors
describe('table API', () => {
beforeEach(() => {
service = createExternalService({
credentials: {
config: { apiUrl: 'https://example.com/', isOAuth: false },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false },
axiosInstance: axios,
});
});
test('it closes the incident correctly', async () => {
mockIncidentResponse(false);
mockImportIncident(true);
mockIncidentResponse(true);
const res = await service.closeIncident({
incidentId: '1',
correlationId: null,
});
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService({
credentials: {
config: { apiUrl: 'https://example.com/', isOAuth: false },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false },
axiosInstance: axios,
});
mockIncidentResponse(false);
mockIncidentResponse(true);
mockIncidentResponse(true);
const res = await service.closeIncident({
incidentId: '1',
correlationId: null,
});
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'patch',
data: {
close_code: 'Closed/Resolved by Caller',
state: '7',
close_notes: 'Closed by Caller',
},
});
expect(requestMock).toHaveBeenNthCalledWith(3, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'get',
});
expect(res?.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
});
});
describe('getFields', () => {
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({

View file

@ -17,6 +17,7 @@ import {
ServiceNowIncident,
GetApplicationInfoResponse,
ServiceFactory,
ExternalServiceParamsClose,
} from './types';
import * as i18n from './translations';
@ -75,6 +76,10 @@ export const createExternalService: ServiceFactory = ({
return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`;
};
const getIncidentByCorrelationIdUrl = (correlationId: string) => {
return `${tableApiIncidentUrl}?sysparm_query=ORDERBYDESCsys_created_on^correlation_id=${correlationId}`;
};
const getChoicesURL = (fields: string[]) => {
const elements = fields
.slice(1)
@ -169,6 +174,7 @@ export const createExternalService: ServiceFactory = ({
params,
configurationUtilities,
});
checkInstance(res);
return res.data.result.length > 0 ? { ...res.data.result } : undefined;
} catch (error) {
@ -249,6 +255,87 @@ export const createExternalService: ServiceFactory = ({
}
};
const getIncidentByCorrelationId = async (
correlationId: string
): Promise<ServiceNowIncident | null> => {
try {
const res = await request({
axios: axiosInstance,
url: getIncidentByCorrelationIdUrl(correlationId),
method: 'get',
logger,
configurationUtilities,
});
checkInstance(res);
const foundIncident = res.data.result[0] ?? null;
return foundIncident;
} catch (error) {
throw createServiceError(error, `Unable to get incident by correlation ID ${correlationId}`);
}
};
const closeIncident = async (params: ExternalServiceParamsClose) => {
try {
const { correlationId, incidentId } = params;
let incidentToBeClosed = null;
if (correlationId == null && incidentId == null) {
throw new Error('No correlationId or incidentId found.');
}
if (incidentId) {
incidentToBeClosed = await getIncident(incidentId);
} else if (correlationId) {
incidentToBeClosed = await getIncidentByCorrelationId(correlationId);
}
if (incidentToBeClosed === null) {
logger.warn(
`[ServiceNow][CloseIncident] No incident found with correlation_id: ${correlationId} or incidentId: ${incidentId}.`
);
return null;
}
if (incidentToBeClosed.state === '7') {
logger.warn(
`[ServiceNow][CloseIncident] Incident with correlation_id: ${correlationId} or incidentId: ${incidentId} is closed.`
);
return {
title: incidentToBeClosed.number,
id: incidentToBeClosed.sys_id,
pushedDate: getPushedDate(incidentToBeClosed.sys_updated_on),
url: getIncidentViewURL(incidentToBeClosed.sys_id),
};
}
const closedIncident = await updateIncident({
incidentId: incidentToBeClosed.sys_id,
incident: {
state: '7', // used for "closed" status in serviceNow
close_code: 'Closed/Resolved by Caller',
close_notes: 'Closed by Caller',
},
});
return closedIncident;
} catch (error) {
if (error?.response?.status === 404) {
logger.warn(
`[ServiceNow][CloseIncident] No incident found with incidentId: ${params.incidentId}.`
);
return null;
}
throw createServiceError(error, 'Unable to close incident');
}
};
const getFields = async () => {
try {
const res = await request({
@ -292,5 +379,7 @@ export const createExternalService: ServiceFactory = ({
checkInstance,
getApplicationInformation,
checkIfApplicationIsInstalled,
closeIncident,
getIncidentByCorrelationId,
};
};

View file

@ -26,6 +26,7 @@ import {
ExecutorParamsSchemaITOM,
ExecutorSubActionAddEventParamsSchema,
ExternalIncidentServiceConfigurationBaseSchema,
ExecutorSubActionCloseIncidentParamsSchema,
} from './schema';
import { SNProductsConfigValue } from '../../../../common/servicenow_config';
@ -104,17 +105,26 @@ export interface ExternalServiceParamsUpdate {
incident: PartialIncident & Record<string, unknown>;
}
export interface ExternalServiceParamsClose {
incidentId: string | null;
correlationId: string | null;
}
export interface ExternalService {
getChoices: (fields: string[]) => Promise<GetChoicesResponse>;
getIncident: (id: string) => Promise<ServiceNowIncident>;
getFields: () => Promise<GetCommonFieldsResponse>;
createIncident: (params: ExternalServiceParamsCreate) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (params: ExternalServiceParamsUpdate) => Promise<ExternalServiceIncidentResponse>;
closeIncident: (
params: ExternalServiceParamsClose
) => Promise<ExternalServiceIncidentResponse | null>;
findIncidents: (params?: Record<string, string>) => Promise<ServiceNowIncident>;
getUrl: () => string;
checkInstance: (res: AxiosResponse) => void;
getApplicationInformation: () => Promise<GetApplicationInfoResponse>;
checkIfApplicationIsInstalled: () => Promise<void>;
getIncidentByCorrelationId: (correlationId: string) => Promise<ServiceNowIncident | null>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
@ -134,6 +144,10 @@ export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export type ExecutorSubActionCloseIncidentParams = TypeOf<
typeof ExecutorSubActionCloseIncidentParamsSchema
>;
export type ServiceNowITSMIncident = Omit<
TypeOf<typeof ExecutorSubActionPushParamsSchemaITSM>['incident'],
'externalId'
@ -155,6 +169,10 @@ export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs
params: ExecutorSubActionGetIncidentParams;
}
export interface CloseIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionCloseIncidentParams;
}
export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionHandshakeParams;
}
@ -199,6 +217,9 @@ export interface ExternalServiceAPI {
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<ServiceNowIncident>;
closeIncident: (
args: CloseIncidentApiHandlerArgs
) => Promise<ExternalServiceIncidentResponse | null>;
}
export interface ExternalServiceCommentResponse {

View file

@ -28,6 +28,7 @@ jest.mock('@kbn/actions-plugin/server/lib/get_oauth_jwt_access_token', () => ({
jest.mock('axios', () => ({
create: jest.fn(),
AxiosHeaders: jest.requireActual('axios').AxiosHeaders,
AxiosError: jest.requireActual('axios').AxiosError,
}));
const createAxiosInstanceMock = axios.create as jest.Mock;
const axiosInstanceMock = {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import axios, { AxiosHeaders, AxiosInstance, AxiosResponse } from 'axios';
import axios, { AxiosError, AxiosHeaders, AxiosInstance, AxiosResponse } from 'axios';
import { Logger } from '@kbn/core/server';
import { addTimeZoneToDate, getErrorMessage } from '@kbn/actions-plugin/server/lib/axios_utils';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
@ -42,14 +42,22 @@ const createErrorMessage = (errorResponse?: ServiceNowError): string => {
: 'unknown: no error in error response';
};
export const createServiceError = (error: ResponseError, message: string) =>
new Error(
export const createServiceError = (error: ResponseError, message: string): AxiosError => {
const serviceError = new AxiosError(
getErrorMessage(
i18n.SERVICENOW,
`${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}`
)
);
serviceError.code = error.code;
serviceError.config = error.config;
serviceError.request = error.request;
serviceError.response = error.response;
return serviceError;
};
export const getPushedDate = (timestamp?: string) => {
if (timestamp != null) {
return new Date(addTimeZoneToDate(timestamp)).toISOString();

View file

@ -23,6 +23,7 @@ jest.mock('./api', () => ({
getIncident: jest.fn(),
handshake: jest.fn(),
pushToService: jest.fn(),
closeIncident: jest.fn(),
},
}));
@ -75,6 +76,33 @@ describe('ServiceNow', () => {
'work_notes'
);
});
test('calls closeIncident sub action correctly', async () => {
const actionId = 'some-action-id';
const executorOptions = {
actionId,
config,
secrets,
params: {
subAction: 'closeIncident',
subActionParams: {
incident: {
correlationId: 'custom_correlation_id',
externalId: null,
},
},
},
services,
logger: mockedLogger,
} as unknown as ServiceNowConnectorTypeExecutorOptions<
ServiceNowPublicConfigurationType,
ExecutorParams
>;
await connectorType.executor(executorOptions);
expect(
(api.closeIncident as jest.Mock).mock.calls[0][0].params.incident.correlationId
).toBe('custom_correlation_id');
});
});
});
});

View file

@ -41,6 +41,7 @@ import {
ServiceNowExecutorResultData,
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorSubActionCloseIncidentParams,
} from '../lib/servicenow/types';
import {
ServiceNowITSMConnectorTypeId,
@ -102,7 +103,13 @@ export function getServiceNowITSMConnectorType(): ServiceNowConnectorType<
}
// action executor
const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident'];
const supportedSubActions: string[] = [
'getFields',
'pushToService',
'getChoices',
'getIncident',
'closeIncident',
];
async function executor(
{
actionTypeId,
@ -173,5 +180,14 @@ async function executor(
});
}
if (subAction === 'closeIncident') {
const closeIncidentParams = subActionParams as ExecutorSubActionCloseIncidentParams;
data = await api.closeIncident({
externalService,
params: closeIncidentParams,
logger,
});
}
return { status: 'ok', data: data ?? {}, actionId };
}

View file

@ -17,7 +17,10 @@ import { serviceNowCommonFields, serviceNowChoices } from '../lib/servicenow/moc
import { snExternalServiceConfig } from '../lib/servicenow/config';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('axios', () => ({
create: jest.fn(),
AxiosError: jest.requireActual('axios').AxiosError,
}));
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {

View file

@ -549,6 +549,7 @@ export const ActionTypeForm = ({
actionParams={actionItem.params as any}
errors={actionParamsErrors.errors}
index={index}
selectedActionGroupId={selectedActionGroup?.id}
editAction={(key: string, value: RuleActionParam, i: number) => {
setWarning(
validateParamsForWarnings(

View file

@ -224,6 +224,7 @@ export interface ActionParamsProps<TParams> {
actionConnector?: ActionConnector;
isLoading?: boolean;
isDisabled?: boolean;
selectedActionGroupId?: string;
showEmailSubjectAndMessage?: boolean;
executionMode?: ActionConnectorMode;
onBlur?: (field?: string) => void;

View file

@ -466,7 +466,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [getChoices]\n- [5.subAction]: expected value to equal [closeIncident]',
});
});
});
@ -484,7 +484,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]\n- [5.subAction]: expected value to equal [closeIncident]',
});
});
});
@ -507,7 +507,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.short_description]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [getChoices]\n- [5.subAction]: expected value to equal [closeIncident]',
});
});
});
@ -534,7 +534,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]\n- [5.subAction]: expected value to equal [closeIncident]',
});
});
});
@ -561,7 +561,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [getChoices]\n- [5.subAction]: expected value to equal [closeIncident]',
});
});
});
@ -583,7 +583,7 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subActionParams.fields]: expected value of type [array] but got [undefined]\n- [5.subAction]: expected value to equal [closeIncident]',
});
});
});
@ -716,6 +716,32 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
});
});
});
describe('closeIncident', () => {
it('should close the incident', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'closeIncident',
subActionParams: {
incident: {
correlation_id: 'custom_correlation_id',
},
},
},
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(result).to.eql({
status: 'ok',
connector_id: simulatedActionId,
data: {},
});
});
});
});
});
});