Skip to content

Commit

Permalink
[7.x] ServiceNow push to Incident generic implementation (supporting …
Browse files Browse the repository at this point in the history
…both Case specific and generic Alerts) (#68464) (#70898)

* ServiceNow push to Incident generic implementation (supporting both Case specific and generic Alerts) (#68464)

* Draft ServiceNow generic implementation

* simple working servicenow incident per alert

* fixed running times

* rely on externalId for update incident on the next execution

* Added consumer to the action type to be able to split ServiceNow for Cases and Alerts

* Added subActions support for ServiceNow action form

* Basic version for Alerts part for ServiceNow

* Keep Case ServiceNow functionality working

* Revert changes on app_router

* Fixed type checks

* Fixed language check issues

* Fixed actions unit tests

* Fixed functional tests

* Fixed jest tests

* fixed tests

* Copied case mappings to alerting plugin

* made consumer optional

* Cleanup tests

* more cleanup

* Fixed jest tests and type checks

* fixed tests

* fixed servicenow validation tests

* Added ServiceNow unit tests

* Removed consumer for actions

* fixed client side isCaseOwned support

* fixed failing tests

* fixed jest tests

* Fixed URL validation

* fixed due to comments

* fixed tests

* fixed jest tests

* Fixed due to comments. Moved ServiceNow filtering in case plugin to server side

* fixed mock for ServiceNow

* fixed consumer config

* fixed test

* fixed type check

* Fixed jest test

* fixed type check

* fixed internationalization check
  • Loading branch information
YulNaumenko authored Jul 7, 2020
1 parent 0c064da commit 6da5b3f
Show file tree
Hide file tree
Showing 80 changed files with 2,559 additions and 803 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ two out-of-the box connectors: <<slack-action-type, Slack>> and <<webhook-action
my-slack1: <1>
actionTypeId: .slack <2>
name: 'Slack #xyz' <3>
secrets: <4>
secrets:
webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz'
webhook-service:
actionTypeId: .webhook
name: 'Email service'
config:
config: <4>
url: 'https://email-alert-service.elastic.co'
method: post
headers:
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/actions/server/actions_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export class ActionsClient {

this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);

const result = await this.savedObjectsClient.update('action', id, {
const result = await this.savedObjectsClient.update<RawAction>('action', id, {
actionTypeId,
name,
config: validatedActionTypeConfig as SavedObjectAttributes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const pushToServiceHandler = async ({
}

const fields = prepareFieldsForTransformation({
params,
externalCase: params.externalCase,
mapping,
defaultPipes,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);

export const ExecutorSubActionPushParamsSchema = schema.object({
caseId: schema.string(),
savedObjectId: schema.string(),
title: schema.string(),
description: schema.nullable(schema.string()),
comments: schema.nullable(schema.arrayOf(CommentSchema)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export interface PipedField {
}

export interface PrepareFieldsForTransformArgs {
params: PushToServiceApiParams;
externalCase: Record<string, any>;
mapping: Map<string, MapRecord>;
defaultPipes?: string[];
}
Expand Down
139 changes: 10 additions & 129 deletions x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/

import axios from 'axios';

import {
normalizeMapping,
buildMap,
mapParams,
prepareFieldsForTransformation,
transformFields,
transformComments,
addTimeZoneToDate,
throwIfNotAlive,
request,
patch,
getErrorMessage,
} from './utils';

import { SUPPORTED_SOURCE_FIELDS } from './constants';
import { Comment, MapRecord, PushToServiceApiParams } from './types';

jest.mock('axios');
const axiosMock = (axios as unknown) as jest.Mock;

const mapping: MapRecord[] = [
{ source: 'title', target: 'short_description', actionType: 'overwrite' },
{ source: 'description', target: 'description', actionType: 'append' },
Expand Down Expand Up @@ -63,7 +53,7 @@ const maliciousMapping: MapRecord[] = [
];

const fullParams: PushToServiceApiParams = {
caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
title: 'a title',
description: 'a description',
createdAt: '2020-03-13T08:34:53.450Z',
Expand Down Expand Up @@ -132,7 +122,7 @@ describe('buildMap', () => {
describe('mapParams', () => {
test('maps params correctly', () => {
const params = {
caseId: '123',
savedObjectId: '123',
incidentId: '456',
title: 'Incident title',
description: 'Incident description',
Expand All @@ -148,7 +138,7 @@ describe('mapParams', () => {

test('do not add fields not in mapping', () => {
const params = {
caseId: '123',
savedObjectId: '123',
incidentId: '456',
title: 'Incident title',
description: 'Incident description',
Expand All @@ -164,7 +154,7 @@ describe('mapParams', () => {
describe('prepareFieldsForTransformation', () => {
test('prepare fields with defaults', () => {
const res = prepareFieldsForTransformation({
params: fullParams,
externalCase: fullParams.externalCase,
mapping: finalMapping,
});
expect(res).toEqual([
Expand All @@ -185,7 +175,7 @@ describe('prepareFieldsForTransformation', () => {

test('prepare fields with default pipes', () => {
const res = prepareFieldsForTransformation({
params: fullParams,
externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['myTestPipe'],
});
Expand All @@ -209,7 +199,7 @@ describe('prepareFieldsForTransformation', () => {
describe('transformFields', () => {
test('transform fields for creation correctly', () => {
const fields = prepareFieldsForTransformation({
params: fullParams,
externalCase: fullParams.externalCase,
mapping: finalMapping,
});

Expand All @@ -226,14 +216,7 @@ describe('transformFields', () => {

test('transform fields for update correctly', () => {
const fields = prepareFieldsForTransformation({
params: {
...fullParams,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
username: 'anotherUser',
fullName: 'Another User',
},
},
externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
Expand Down Expand Up @@ -262,7 +245,7 @@ describe('transformFields', () => {

test('add newline character to descripton', () => {
const fields = prepareFieldsForTransformation({
params: fullParams,
externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
Expand All @@ -280,7 +263,7 @@ describe('transformFields', () => {

test('append username if fullname is undefined when create', () => {
const fields = prepareFieldsForTransformation({
params: fullParams,
externalCase: fullParams.externalCase,
mapping: finalMapping,
});

Expand All @@ -300,14 +283,7 @@ describe('transformFields', () => {

test('append username if fullname is undefined when update', () => {
const fields = prepareFieldsForTransformation({
params: {
...fullParams,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
username: 'anotherUser',
fullName: 'Another User',
},
},
externalCase: fullParams.externalCase,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
Expand Down Expand Up @@ -479,98 +455,3 @@ describe('transformComments', () => {
]);
});
});

describe('addTimeZoneToDate', () => {
test('adds timezone with default', () => {
const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z');
expect(date).toBe('2020-04-14T15:01:55.456Z GMT');
});

test('adds timezone correctly', () => {
const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z', 'PST');
expect(date).toBe('2020-04-14T15:01:55.456Z PST');
});
});

describe('throwIfNotAlive ', () => {
test('throws correctly when status is invalid', async () => {
expect(() => {
throwIfNotAlive(404, 'application/json');
}).toThrow('Instance is not alive.');
});

test('throws correctly when content is invalid', () => {
expect(() => {
throwIfNotAlive(200, 'application/html');
}).toThrow('Instance is not alive.');
});

test('do NOT throws with custom validStatusCodes', async () => {
expect(() => {
throwIfNotAlive(404, 'application/json', [404]);
}).not.toThrow('Instance is not alive.');
});
});

describe('request', () => {
beforeEach(() => {
axiosMock.mockImplementation(() => ({
status: 200,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
}));
});

test('it fetch correctly with defaults', async () => {
const res = await request({ axios, url: '/test' });

expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {} });
expect(res).toEqual({
status: 200,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
});
});

test('it fetch correctly', async () => {
const res = await request({ axios, url: '/test', method: 'post', data: { id: '123' } });

expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' } });
expect(res).toEqual({
status: 200,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
});
});

test('it throws correctly', async () => {
axiosMock.mockImplementation(() => ({
status: 404,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
}));

await expect(request({ axios, url: '/test' })).rejects.toThrow();
});
});

describe('patch', () => {
beforeEach(() => {
axiosMock.mockImplementation(() => ({
status: 200,
headers: { 'content-type': 'application/json' },
}));
});

test('it fetch correctly', async () => {
await patch({ axios, url: '/test', data: { id: '123' } });
expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' } });
});
});

describe('getErrorMessage', () => {
test('it returns the correct error message', () => {
const msg = getErrorMessage('My connector name', 'An error has occurred');
expect(msg).toBe('[Action][My connector name]: An error has occurred');
});
});
54 changes: 3 additions & 51 deletions x-pack/plugins/actions/server/builtin_action_types/case/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import { curry, flow, get } from 'lodash';
import { schema } from '@kbn/config-schema';
import { AxiosInstance, Method, AxiosResponse } from 'axios';

import { ActionTypeExecutorOptions, ActionTypeExecutorResult, ActionType } from '../../types';

Expand Down Expand Up @@ -134,65 +133,18 @@ export const createConnector = ({
});
};

export const throwIfNotAlive = (
status: number,
contentType: string,
validStatusCodes: number[] = [200, 201, 204]
) => {
if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) {
throw new Error('Instance is not alive.');
}
};

export const request = async <T = unknown>({
axios,
url,
method = 'get',
data,
}: {
axios: AxiosInstance;
url: string;
method?: Method;
data?: T;
}): Promise<AxiosResponse> => {
const res = await axios(url, { method, data: data ?? {} });
throwIfNotAlive(res.status, res.headers['content-type']);
return res;
};

export const patch = async <T = unknown>({
axios,
url,
data,
}: {
axios: AxiosInstance;
url: string;
data: T;
}): Promise<AxiosResponse> => {
return request({
axios,
url,
method: 'patch',
data,
});
};

export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => {
return `${date} ${timezone}`;
};

export const prepareFieldsForTransformation = ({
params,
externalCase,
mapping,
defaultPipes = ['informationCreated'],
}: PrepareFieldsForTransformArgs): PipedField[] => {
return Object.keys(params.externalCase)
return Object.keys(externalCase)
.filter((p) => mapping.get(p)?.actionType != null && mapping.get(p)?.actionType !== 'nothing')
.map((p) => {
const actionType = mapping.get(p)?.actionType ?? 'nothing';
return {
key: p,
value: params.externalCase[p],
value: externalCase[p],
actionType,
pipes: actionType === 'append' ? [...defaultPipes, 'append'] : defaultPipes,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getServerLogActionType({ logger }));
actionTypeRegistry.register(getSlackActionType({ configurationUtilities }));
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities }));
actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getJiraActionType({ configurationUtilities }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ mapping.set('summary', {
});

const executorParams: ExecutorSubActionPushParams = {
caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
externalId: 'incident-3',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import axios from 'axios';

import { createExternalService } from './service';
import * as utils from '../case/utils';
import * as utils from '../lib/axios_utils';
import { ExternalService } from '../case/types';

jest.mock('axios');
jest.mock('../case/utils', () => {
const originalUtils = jest.requireActual('../case/utils');
jest.mock('../lib/axios_utils', () => {
const originalUtils = jest.requireActual('../lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
Expand Down
Loading

0 comments on commit 6da5b3f

Please sign in to comment.