[Cases] Update IBM resilient connector to use sub action framework (#180561)

## Summary

Fixes https://github.com/elastic/response-ops-team/issues/186
This PR updates IBM resilient connector to use CaseConnector of sub
action framework.

### Steps to verify
Expectation: IBM connector should work as before in all below scenarios:
- Create an IBM resilient connector
- Test the connector
- Create an alert and use this connector as action
- Use this connector in Cases

### Flaky test runner
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5667

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2024-04-25 08:40:58 +02:00 committed by GitHub
parent 4dcc3c985f
commit b3f7c5cf0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1782 additions and 2705 deletions

View file

@ -6068,6 +6068,336 @@ Object {
`;
exports[`Connector type config checks detect connector type changes for: .resilient 1`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"comments": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"comment": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"commentId": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
],
"type": "array",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"incident": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"description": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"externalId": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"incidentTypes": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"type": "number",
},
],
"type": "array",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"name": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"severityCode": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"type": "number",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .resilient 2`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .resilient 3`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .resilient 4`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
}
`;
exports[`Connector type config checks detect connector type changes for: .resilient 5`] = `
Object {
"flags": Object {
"default": Object {
@ -6115,7 +6445,7 @@ Object {
}
`;
exports[`Connector type config checks detect connector type changes for: .resilient 2`] = `
exports[`Connector type config checks detect connector type changes for: .resilient 6`] = `
Object {
"flags": Object {
"default": Object {
@ -6163,553 +6493,54 @@ Object {
}
`;
exports[`Connector type config checks detect connector type changes for: .resilient 3`] = `
exports[`Connector type config checks detect connector type changes for: .resilient 7`] = `
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"subAction": Object {
"allow": Array [
"getFields",
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
"keys": Object {
"subAction": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
Object {
"schema": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"keys": Object {
"subAction": Object {
"allow": Array [
"getIncident",
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"externalId": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
"error": [Function],
"presence": "optional",
"unknown": true,
},
},
Object {
"schema": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
"keys": Object {
"subAction": Object {
"allow": Array [
"handshake",
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
"type": "object",
},
Object {
"schema": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"subAction": Object {
"allow": Array [
"pushToService",
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"comments": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"comment": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"commentId": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
],
"type": "array",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"incident": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"description": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"externalId": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"incidentTypes": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"type": "number",
},
],
"type": "array",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"name": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"severityCode": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"error": [Function],
},
"type": "number",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
Object {
"schema": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"subAction": Object {
"allow": Array [
"incidentTypes",
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
Object {
"schema": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"subAction": Object {
"allow": Array [
"severity",
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
"subActionParams": Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
"preferences": Object {
"stripUnknown": Object {
"objects": false,
},
},
"type": "object",
},
},
],
"type": "alternatives",
},
"type": "object",
}
`;

View file

@ -20,7 +20,6 @@ export const connectorTypes: string[] = [
'.servicenow-sir',
'.servicenow-itom',
'.jira',
'.resilient',
'.teams',
'.torq',
'.opsgenie',
@ -28,6 +27,7 @@ export const connectorTypes: string[] = [
'.gen-ai',
'.bedrock',
'.d3security',
'.resilient',
'.sentinelone',
'.cases',
'.observability-ai-assistant',

View file

@ -9,11 +9,11 @@ import { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-
import { getConnectorType as getCasesWebhookConnectorType } from './cases_webhook';
import { getConnectorType as getJiraConnectorType } from './jira';
import { getConnectorType as getResilientConnectorType } from './resilient';
import { getServiceNowITSMConnectorType } from './servicenow_itsm';
import { getServiceNowSIRConnectorType } from './servicenow_sir';
import { getServiceNowITOMConnectorType } from './servicenow_itom';
import { getTinesConnectorType } from './tines';
import { getResilientConnectorType } from './resilient';
import { getActionType as getTorqConnectorType } from './torq';
import { getConnectorType as getEmailConnectorType } from './email';
import { getConnectorType as getIndexConnectorType } from './es_index';
@ -39,8 +39,6 @@ export { ConnectorTypeId as CasesWebhookConnectorTypeId } from './cases_webhook'
export type { ActionParamsType as CasesWebhookActionParams } from './cases_webhook';
export { ConnectorTypeId as JiraConnectorTypeId } from './jira';
export type { ActionParamsType as JiraActionParams } from './jira';
export { ConnectorTypeId as ResilientConnectorTypeId } from './resilient';
export type { ActionParamsType as ResilientActionParams } from './resilient';
export { ServiceNowITSMConnectorTypeId } from './servicenow_itsm';
export { ServiceNowSIRConnectorTypeId } from './servicenow_sir';
export { ConnectorTypeId as EmailConnectorTypeId } from './email';
@ -100,7 +98,6 @@ export function registerConnectorTypes({
actions.registerType(getServiceNowSIRConnectorType());
actions.registerType(getServiceNowITOMConnectorType());
actions.registerType(getJiraConnectorType());
actions.registerType(getResilientConnectorType());
actions.registerType(getTeamsConnectorType());
actions.registerType(getTorqConnectorType());
@ -109,6 +106,7 @@ export function registerConnectorTypes({
actions.registerSubActionConnectorType(getOpenAIConnectorType());
actions.registerSubActionConnectorType(getBedrockConnectorType());
actions.registerSubActionConnectorType(getD3SecurityConnectorType());
actions.registerSubActionConnectorType(getResilientConnectorType());
if (experimentalFeatures.sentinelOneConnectorOn) {
actions.registerSubActionConnectorType(getSentinelOneConnectorType());

View file

@ -1,214 +0,0 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { api } from './api';
import { externalServiceMock, apiParams } from './mocks';
import { ExternalService } from './types';
let mockedLogger: jest.Mocked<Logger>;
describe('api', () => {
let externalService: jest.Mocked<ExternalService>;
beforeEach(() => {
externalService = externalServiceMock.create();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('pushToService', () => {
describe('create incident', () => {
test('it creates an incident', async () => {
const params = { ...apiParams, externalId: null };
const res = await api.pushToService({
externalService,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
title: '1',
pushedDate: '2020-06-03T15:09:13.606Z',
url: 'https://resilient.elastic.co/#incidents/1',
comments: [
{
commentId: 'case-comment-1',
pushedDate: '2020-06-03T15:09:13.606Z',
},
{
commentId: 'case-comment-2',
pushedDate: '2020-06-03T15:09:13.606Z',
},
],
});
});
test('it creates an incident without comments', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
const res = await api.pushToService({
externalService,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
title: '1',
pushedDate: '2020-06-03T15:09:13.606Z',
url: 'https://resilient.elastic.co/#incidents/1',
});
});
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
incidentTypes: [1001],
severityCode: 6,
description: 'Incident description',
name: 'Incident title',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
test('it calls createComment correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: '1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: '1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
});
describe('update incident', () => {
test('it updates an incident', async () => {
const res = await api.pushToService({
externalService,
params: apiParams,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
title: '1',
pushedDate: '2020-06-03T15:09:13.606Z',
url: 'https://resilient.elastic.co/#incidents/1',
comments: [
{
commentId: 'case-comment-1',
pushedDate: '2020-06-03T15:09:13.606Z',
},
{
commentId: 'case-comment-2',
pushedDate: '2020-06-03T15:09:13.606Z',
},
],
});
});
test('it updates an incident without comments', async () => {
const params = { ...apiParams, comments: [] };
const res = await api.pushToService({
externalService,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
title: '1',
pushedDate: '2020-06-03T15:09:13.606Z',
url: 'https://resilient.elastic.co/#incidents/1',
});
});
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
description: 'Incident description',
name: 'Incident title',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
});
test('it calls createComment correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: '1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: '1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
});
describe('incidentTypes', () => {
test('it returns the incident types correctly', async () => {
const res = await api.incidentTypes({
externalService,
params: {},
});
expect(res).toEqual([
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
]);
});
});
describe('severity', () => {
test('it returns the severity correctly', async () => {
const res = await api.severity({
externalService,
params: { id: '10006' },
});
expect(res).toEqual([
{ id: 4, name: 'Low' },
{ id: 5, name: 'Medium' },
{ id: 6, name: 'High' },
]);
});
});
});
});

View file

@ -1,85 +0,0 @@
/*
* 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 {
PushToServiceApiHandlerArgs,
HandshakeApiHandlerArgs,
GetIncidentApiHandlerArgs,
ExternalServiceApi,
Incident,
GetIncidentTypesHandlerArgs,
GetSeverityHandlerArgs,
PushToServiceResponse,
GetCommonFieldsHandlerArgs,
} from './types';
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {};
const getFieldsHandler = async ({ externalService }: GetCommonFieldsHandlerArgs) => {
const res = await externalService.getFields();
return res;
};
const getIncidentTypesHandler = async ({ externalService }: GetIncidentTypesHandlerArgs) => {
const res = await externalService.getIncidentTypes();
return res;
};
const getSeverityHandler = async ({ externalService }: GetSeverityHandlerArgs) => {
const res = await externalService.getSeverity();
return res;
};
const pushToServiceHandler = async ({
externalService,
params,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const { comments } = params;
let res: PushToServiceResponse;
const { externalId, ...rest } = params.incident;
const incident: Incident = rest;
if (externalId != null) {
res = await externalService.updateIncident({
incidentId: externalId,
incident,
});
} else {
res = await externalService.createIncident({
incident,
});
}
if (comments && Array.isArray(comments) && comments.length > 0) {
res.comments = [];
for (const currentComment of comments) {
const comment = await externalService.createComment({
incidentId: res.id,
comment: currentComment,
});
res.comments = [
...(res.comments ?? []),
{
commentId: comment.commentId,
pushedDate: comment.pushedDate,
},
];
}
}
return res;
};
export const api: ExternalServiceApi = {
getFields: getFieldsHandler,
getIncident: getIncidentHandler,
handshake: handshakeHandler,
incidentTypes: getIncidentTypesHandler,
pushToService: pushToServiceHandler,
severity: getSeverityHandler,
};

View file

@ -0,0 +1,14 @@
/*
* 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 RESILIENT_CONNECTOR_ID = '.resilient';
export enum SUB_ACTION {
FIELDS = 'getFields',
SEVERITY = 'severity',
INCIDENT_TYPES = 'incidentTypes',
}

View file

@ -5,144 +5,43 @@
* 2.0.
*/
import { TypeOf } from '@kbn/config-schema';
import type {
ActionType as ConnectorType,
ActionTypeExecutorOptions as ConnectorTypeExecutorOptions,
ActionTypeExecutorResult as ConnectorTypeExecutorResult,
} from '@kbn/actions-plugin/server/types';
import {
SubActionConnectorType,
ValidatorType,
} from '@kbn/actions-plugin/server/sub_action_framework/types';
import {
AlertingConnectorFeatureId,
CasesConnectorFeatureId,
SecurityConnectorFeatureId,
} from '@kbn/actions-plugin/common/types';
import { validate } from './validators';
} from '@kbn/actions-plugin/common';
import { urlAllowListValidator } from '@kbn/actions-plugin/server';
import { ResilientConfig, ResilientSecrets } from './types';
import { RESILIENT_CONNECTOR_ID } from './constants';
import * as i18n from './translations';
import {
ExternalIncidentServiceConfigurationSchema,
ExternalIncidentServiceSecretConfigurationSchema,
ExecutorParamsSchema,
PushToServiceIncidentSchema,
} from './schema';
import { createExternalService } from './service';
import { api } from './api';
import {
ExecutorParams,
ExecutorSubActionPushParams,
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ResilientExecutorResultData,
ExecutorSubActionGetIncidentTypesParams,
ExecutorSubActionGetSeverityParams,
ExecutorSubActionCommonFieldsParams,
} from './types';
import * as i18n from './translations';
import { ResilientConnector } from './resilient';
export type ActionParamsType = TypeOf<typeof ExecutorParamsSchema>;
const supportedSubActions: string[] = ['getFields', 'pushToService', 'incidentTypes', 'severity'];
export const ConnectorTypeId = '.resilient';
// connector type definition
export function getConnectorType(): ConnectorType<
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ExecutorParams,
ResilientExecutorResultData | {}
> {
return {
id: ConnectorTypeId,
minimumLicenseRequired: 'platinum',
name: i18n.NAME,
supportedFeatureIds: [
AlertingConnectorFeatureId,
CasesConnectorFeatureId,
SecurityConnectorFeatureId,
],
validate: {
config: {
schema: ExternalIncidentServiceConfigurationSchema,
customValidator: validate.config,
},
secrets: {
schema: ExternalIncidentServiceSecretConfigurationSchema,
customValidator: validate.secrets,
},
params: {
schema: ExecutorParamsSchema,
},
},
executor,
};
}
// action executor
async function executor(
execOptions: ConnectorTypeExecutorOptions<
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ExecutorParams
>
): Promise<ConnectorTypeExecutorResult<ResilientExecutorResultData | {}>> {
const { actionId, config, params, secrets, configurationUtilities, logger } = execOptions;
const { subAction, subActionParams } = params as ExecutorParams;
let data: ResilientExecutorResultData | null = null;
const externalService = createExternalService(
{
config,
secrets,
},
logger,
configurationUtilities
);
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
data = await api.pushToService({
externalService,
params: pushToServiceParams,
logger,
});
logger.debug(`response push to service for incident id: ${data.id}`);
}
if (subAction === 'getFields') {
const getFieldsParams = subActionParams as ExecutorSubActionCommonFieldsParams;
data = await api.getFields({
externalService,
params: getFieldsParams,
});
}
if (subAction === 'incidentTypes') {
const incidentTypesParams = subActionParams as ExecutorSubActionGetIncidentTypesParams;
data = await api.incidentTypes({
externalService,
params: incidentTypesParams,
});
}
if (subAction === 'severity') {
const severityParams = subActionParams as ExecutorSubActionGetSeverityParams;
data = await api.severity({
externalService,
params: severityParams,
});
}
return { status: 'ok', data: data ?? {}, actionId };
}
export const getResilientConnectorType = (): SubActionConnectorType<
ResilientConfig,
ResilientSecrets
> => ({
id: RESILIENT_CONNECTOR_ID,
minimumLicenseRequired: 'platinum',
name: i18n.NAME,
getService: (params) => new ResilientConnector(params, PushToServiceIncidentSchema),
schema: {
config: ExternalIncidentServiceConfigurationSchema,
secrets: ExternalIncidentServiceSecretConfigurationSchema,
},
supportedFeatureIds: [
AlertingConnectorFeatureId,
CasesConnectorFeatureId,
SecurityConnectorFeatureId,
],
validators: [{ type: ValidatorType.CONFIG, validator: urlAllowListValidator('apiUrl') }],
});

View file

@ -5,394 +5,55 @@
* 2.0.
*/
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
export const resilientFields = [
{
id: 17,
name: 'name',
text: 'Name',
prefix: null,
type_id: 0,
tooltip: 'A unique name to identify this particular incident.',
text: 'name',
input_type: 'text',
required: 'always',
hide_notification: false,
chosen: false,
default_chosen_by_server: false,
blank_option: false,
internal: true,
uuid: 'ad6ed4f2-8d87-4ba2-81fa-03568a9326cc',
operations: [
'equals',
'not_equals',
'contains',
'not_contains',
'changed',
'changed_to',
'not_changed_to',
'has_a_value',
'not_has_a_value',
],
operation_perms: {
changed_to: {
show_in_manual_actions: false,
show_in_auto_actions: true,
show_in_notifications: true,
},
has_a_value: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_changed_to: {
show_in_manual_actions: false,
show_in_auto_actions: true,
show_in_notifications: true,
},
equals: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
changed: {
show_in_manual_actions: false,
show_in_auto_actions: true,
show_in_notifications: true,
},
contains: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_contains: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_equals: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_has_a_value: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
},
values: [],
perms: {
delete: false,
modify_name: false,
modify_values: false,
modify_blank: false,
modify_required: false,
modify_operations: false,
modify_chosen: false,
modify_default: false,
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
show_in_scripts: true,
modify_type: ['text'],
sort: true,
},
read_only: false,
changeable: true,
rich_text: false,
templates: [],
deprecated: false,
tags: [],
calculated: false,
is_tracked: false,
allow_default_value: false,
},
{
id: 15,
name: 'description',
text: 'Description',
prefix: null,
type_id: 0,
tooltip: 'A free form text description of the incident.',
input_type: 'textarea',
hide_notification: false,
chosen: false,
default_chosen_by_server: false,
blank_option: false,
internal: true,
uuid: '420d70b1-98f9-4681-a20b-84f36a9e5e48',
operations: [
'equals',
'not_equals',
'contains',
'not_contains',
'changed',
'changed_to',
'not_changed_to',
'has_a_value',
'not_has_a_value',
],
operation_perms: {
changed_to: {
show_in_manual_actions: false,
show_in_auto_actions: true,
show_in_notifications: true,
},
has_a_value: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_changed_to: {
show_in_manual_actions: false,
show_in_auto_actions: true,
show_in_notifications: true,
},
equals: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
changed: {
show_in_manual_actions: false,
show_in_auto_actions: true,
show_in_notifications: true,
},
contains: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_contains: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_equals: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_has_a_value: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
},
values: [],
perms: {
delete: false,
modify_name: false,
modify_values: false,
modify_blank: false,
modify_required: false,
modify_operations: false,
modify_chosen: false,
modify_default: false,
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
show_in_scripts: true,
modify_type: ['textarea'],
sort: true,
},
read_only: false,
changeable: true,
rich_text: true,
templates: [],
deprecated: false,
tags: [],
calculated: false,
is_tracked: false,
allow_default_value: false,
},
{
id: 65,
name: 'create_date',
text: 'Date Created',
prefix: null,
type_id: 0,
tooltip: 'The date the incident was created. This field is read-only.',
input_type: 'datetimepicker',
hide_notification: false,
chosen: false,
default_chosen_by_server: false,
blank_option: false,
internal: true,
uuid: 'b4faf728-881a-4e8b-bf0b-d39b720392a1',
operations: ['due_within', 'overdue_by', 'has_a_value', 'not_has_a_value'],
operation_perms: {
has_a_value: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
not_has_a_value: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
due_within: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
overdue_by: {
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
},
},
values: [],
perms: {
delete: false,
modify_name: false,
modify_values: false,
modify_blank: false,
modify_required: false,
modify_operations: false,
modify_chosen: false,
modify_default: false,
show_in_manual_actions: true,
show_in_auto_actions: true,
show_in_notifications: true,
show_in_scripts: true,
modify_type: ['datetimepicker'],
sort: true,
},
read_only: true,
changeable: false,
rich_text: false,
templates: [],
deprecated: false,
tags: [],
calculated: false,
is_tracked: false,
allow_default_value: false,
},
];
const createMock = (): jest.Mocked<ExternalService> => {
const service = {
getFields: jest.fn().mockImplementation(() => Promise.resolve(resilientFields)),
getIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: '1',
name: 'title from ibm resilient',
description: 'description from ibm resilient',
discovered_date: 1589391874472,
create_date: 1591192608323,
inc_last_modified_date: 1591192650372,
})
),
createIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: '1',
title: '1',
pushedDate: '2020-06-03T15:09:13.606Z',
url: 'https://resilient.elastic.co/#incidents/1',
})
),
updateIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: '1',
title: '1',
pushedDate: '2020-06-03T15:09:13.606Z',
url: 'https://resilient.elastic.co/#incidents/1',
})
),
createComment: jest.fn(),
findIncidents: jest.fn(),
getIncidentTypes: jest.fn().mockImplementation(() => [
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
]),
getSeverity: jest.fn().mockImplementation(() => [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
]),
};
service.createComment.mockImplementationOnce(() =>
Promise.resolve({
commentId: 'case-comment-1',
pushedDate: '2020-06-03T15:09:13.606Z',
externalCommentId: '1',
})
);
service.createComment.mockImplementationOnce(() =>
Promise.resolve({
commentId: 'case-comment-2',
pushedDate: '2020-06-03T15:09:13.606Z',
externalCommentId: '2',
})
);
return service;
};
const externalServiceMock = {
create: createMock,
};
const executorParams: ExecutorSubActionPushParams = {
incident: {
externalId: 'incident-3',
name: 'Incident title',
description: 'Incident description',
incidentTypes: [1001],
severityCode: 6,
},
comments: [
export const incidentTypes = {
id: 16,
name: 'incident_type_ids',
text: 'Incident Type',
values: [
{
commentId: 'case-comment-1',
comment: 'A comment',
value: 17,
label: 'Communication error (fax; email)',
enabled: true,
properties: null,
uuid: '4a8d22f7-d89e-4403-85c7-2bafe3b7f2ae',
hidden: false,
default: false,
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
value: 1001,
label: 'Custom type',
enabled: true,
properties: null,
uuid: '3b51c8c2-9758-48f8-b013-bd141f1d2ec9',
hidden: false,
default: false,
},
],
};
const apiParams: PushToServiceApiParams = {
...executorParams,
};
const incidentTypes = [
{
value: 17,
label: 'Communication error (fax; email)',
enabled: true,
properties: null,
uuid: '4a8d22f7-d89e-4403-85c7-2bafe3b7f2ae',
hidden: false,
default: false,
},
{
value: 1001,
label: 'Custom type',
enabled: true,
properties: null,
uuid: '3b51c8c2-9758-48f8-b013-bd141f1d2ec9',
hidden: false,
default: false,
},
];
const severity = [
export const severity = [
{
value: 4,
label: 'Low',
@ -421,5 +82,3 @@ const severity = [
default: false,
},
];
export { externalServiceMock, executorParams, apiParams, incidentTypes, severity };

View file

@ -0,0 +1,587 @@
/*
* 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 { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { resilientFields, incidentTypes, severity } from './mocks';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { ResilientConnector } from './resilient';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { RESILIENT_CONNECTOR_ID } from './constants';
import { PushToServiceIncidentSchema } from './schema';
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
};
});
const requestMock = request as jest.Mock;
const TIMESTAMP = 1589391874472;
const apiUrl = 'https://resilient.elastic.co/';
const orgId = '201';
const apiKeyId = 'keyId';
const apiKeySecret = 'secret';
const ignoredRequestFields = {
axios: undefined,
timeout: undefined,
configurationUtilities: expect.anything(),
logger: expect.anything(),
};
const token = Buffer.from(apiKeyId + ':' + apiKeySecret, 'utf8').toString('base64');
const mockIncidentUpdate = (withUpdateError = false) => {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: {
format: 'html',
content: 'description',
},
incident_type_ids: [1001, 16, 12],
severity_code: 6,
inc_last_modified_date: 1589391874472,
},
})
);
if (withUpdateError) {
requestMock.mockImplementationOnce(() => {
throw new Error('An error has occurred');
});
} else {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
success: true,
id: '1',
inc_last_modified_date: 1589391874472,
},
})
);
}
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title_updated',
description: {
format: 'html',
content: 'desc_updated',
},
inc_last_modified_date: 1589391874472,
},
})
);
};
describe('IBM Resilient connector', () => {
const connector = new ResilientConnector(
{
connector: { id: '1', type: RESILIENT_CONNECTOR_ID },
configurationUtilities: actionsConfigMock.create(),
logger: loggingSystemMock.createLogger(),
services: actionsMock.createServices(),
config: { orgId, apiUrl },
secrets: { apiKeyId, apiKeySecret },
},
PushToServiceIncidentSchema
);
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.resetAllMocks();
jest.setSystemTime(TIMESTAMP);
});
describe('getIncident', () => {
const incidentMock = {
id: '1',
name: '1',
description: {
format: 'html',
content: 'description',
},
inc_last_modified_date: TIMESTAMP,
};
beforeEach(() => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: incidentMock,
})
);
});
it('returns the incident correctly', async () => {
const res = await connector.getIncident({ id: '1' });
expect(res).toEqual(incidentMock);
});
it('should call request with correct arguments', async () => {
await connector.getIncident({ id: '1' });
expect(requestMock).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'GET',
data: {},
url: `${apiUrl}rest/orgs/${orgId}/incidents/1`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
params: {
text_content_output_format: 'objects_convert',
},
});
});
it('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(connector.getIncident({ id: '1' })).rejects.toThrow(
'Unable to get incident with id 1. Error: An error has occurred'
);
});
});
describe('createIncident', () => {
const incidentMock = {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
};
beforeEach(() => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: 'description',
discovered_date: 1589391874472,
create_date: 1589391874472,
},
})
);
});
it('creates the incident correctly', async () => {
const res = await connector.createIncident(incidentMock);
expect(res).toEqual({
title: '1',
id: '1',
pushedDate: '2020-05-13T17:44:34.472Z',
url: 'https://resilient.elastic.co/#incidents/1',
});
});
it('should call request with correct arguments', async () => {
await connector.createIncident(incidentMock);
expect(requestMock).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'POST',
data: {
name: 'title',
description: {
format: 'html',
content: 'desc',
},
discovered_date: TIMESTAMP,
incident_type_ids: [{ id: 1001 }],
severity_code: { id: 6 },
},
url: `${apiUrl}rest/orgs/${orgId}/incidents?text_content_output_format=objects_convert`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
});
});
it('should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(
connector.createIncident({
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
})
).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred'
);
});
it('should throw if the required attributes are not received in response', async () => {
requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } }));
await expect(connector.createIncident(incidentMock)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create incident. Error: Response validation failed (Error: [id]: expected value of type [number] but got [undefined]).'
);
});
});
describe('updateIncident', () => {
const req = {
incidentId: '1',
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
};
it('updates the incident correctly', async () => {
mockIncidentUpdate();
const res = await connector.updateIncident(req);
expect(res).toEqual({
title: '1',
id: '1',
pushedDate: '2020-05-13T17:44:34.472Z',
url: 'https://resilient.elastic.co/#incidents/1',
});
});
it('should call request with correct arguments', async () => {
mockIncidentUpdate();
await connector.updateIncident({
incidentId: '1',
incident: {
name: 'title_updated',
description: 'desc_updated',
incidentTypes: [1001],
severityCode: 5,
},
});
expect(requestMock.mock.calls[1][0]).toEqual({
...ignoredRequestFields,
url: `${apiUrl}rest/orgs/${orgId}/incidents/1`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
method: 'PATCH',
data: {
changes: [
{
field: { name: 'name' },
old_value: { text: 'title' },
new_value: { text: 'title_updated' },
},
{
field: { name: 'description' },
old_value: {
textarea: {
content: 'description',
format: 'html',
},
},
new_value: {
textarea: {
content: 'desc_updated',
format: 'html',
},
},
},
{
field: {
name: 'incident_type_ids',
},
old_value: {
ids: [1001, 16, 12],
},
new_value: {
ids: [1001],
},
},
{
field: {
name: 'severity_code',
},
old_value: {
id: 6,
},
new_value: {
id: 5,
},
},
],
},
});
});
it('it should throw an error', async () => {
mockIncidentUpdate(true);
await expect(connector.updateIncident(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred'
);
});
it('should throw if the required attributes are not received in response', async () => {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: {
format: 'html',
content: 'description',
},
incident_type_ids: [1001, 16, 12],
severity_code: 6,
inc_last_modified_date: 1589391874472,
},
})
);
requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } }));
await expect(connector.updateIncident(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to update incident with id 1. Error: Response validation failed (Error: [success]: expected value of type [boolean] but got [undefined]).'
);
});
});
describe('createComment', () => {
const req = {
incidentId: '1',
comment: 'comment',
};
beforeEach(() => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
create_date: 1589391874472,
comment: {
id: '5',
},
},
})
);
});
it('should call request with correct arguments', async () => {
await connector.addComment(req);
expect(requestMock).toHaveBeenCalledWith({
...ignoredRequestFields,
url: `${apiUrl}rest/orgs/${orgId}/incidents/1/comments`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
method: 'POST',
data: {
text: {
content: 'comment',
format: 'text',
},
},
});
});
it('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(connector.addComment(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred.'
);
});
});
describe('getIncidentTypes', () => {
beforeEach(() => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: incidentTypes,
})
);
});
it('should call request with correct arguments', async () => {
await connector.getIncidentTypes();
expect(requestMock).toBeCalledTimes(1);
expect(requestMock).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'GET',
data: {},
url: `${apiUrl}rest/orgs/${orgId}/types/incident/fields/incident_type_ids`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
});
});
it('returns incident types correctly', async () => {
const res = await connector.getIncidentTypes();
expect(res).toEqual([
{ id: '17', name: 'Communication error (fax; email)' },
{ id: '1001', name: 'Custom type' },
]);
});
it('should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(connector.getIncidentTypes()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.'
);
});
it('should throw if the required attributes are not received in response', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1001', name: 'Custom type' } })
);
await expect(connector.getIncidentTypes()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident types. Error: Response validation failed (Error: [values]: expected value of type [array] but got [undefined]).'
);
});
});
describe('getSeverity', () => {
beforeEach(() => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
values: severity,
},
})
);
});
it('should call request with correct arguments', async () => {
await connector.getSeverity();
expect(requestMock).toBeCalledTimes(1);
expect(requestMock).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'GET',
data: {},
url: `${apiUrl}rest/orgs/${orgId}/types/incident/fields/severity_code`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
});
});
it('returns severity correctly', async () => {
const res = await connector.getSeverity();
expect(res).toEqual([
{
id: '4',
name: 'Low',
},
{
id: '5',
name: 'Medium',
},
{
id: '6',
name: 'High',
},
]);
});
it('should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(connector.getSeverity()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.'
);
});
it('should throw if the required attributes are not received in response', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '10', name: 'Critical' } })
);
await expect(connector.getSeverity()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get severity. Error: Response validation failed (Error: [values]: expected value of type [array] but got [undefined]).'
);
});
});
describe('getFields', () => {
beforeEach(() => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: resilientFields,
})
);
});
it('should call request with correct arguments', async () => {
await connector.getFields();
expect(requestMock).toBeCalledTimes(1);
expect(requestMock).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'GET',
data: {},
url: `${apiUrl}rest/orgs/${orgId}/types/incident/fields`,
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/json',
},
});
});
it('returns common fields correctly', async () => {
const res = await connector.getFields();
expect(res).toEqual(resilientFields);
});
it('should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(connector.getFields()).rejects.toThrow(
'Unable to get fields. Error: An error has occurred'
);
});
it('should throw if the required attributes are not received in response', async () => {
requestMock.mockImplementation(() => createAxiosResponse({ data: { someField: 'test' } }));
await expect(connector.getFields()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get fields. Error: Response validation failed (Error: expected value of type [array] but got [Object]).'
);
});
});
});

View file

@ -0,0 +1,331 @@
/*
* 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 { AxiosError } from 'axios';
import { omitBy, isNil } from 'lodash/fp';
import { CaseConnector, ServiceParams } from '@kbn/actions-plugin/server';
import { schema, Type } from '@kbn/config-schema';
import { getErrorMessage } from '@kbn/actions-plugin/server/lib/axios_utils';
import {
CreateIncidentData,
ExternalServiceIncidentResponse,
GetIncidentResponse,
GetIncidentTypesResponse,
GetSeverityResponse,
Incident,
ResilientConfig,
ResilientSecrets,
UpdateIncidentParams,
} from './types';
import * as i18n from './translations';
import { SUB_ACTION } from './constants';
import {
ExecutorSubActionCommonFieldsParamsSchema,
ExecutorSubActionGetIncidentTypesParamsSchema,
ExecutorSubActionGetSeverityParamsSchema,
GetCommonFieldsResponseSchema,
GetIncidentTypesResponseSchema,
GetSeverityResponseSchema,
GetIncidentResponseSchema,
} from './schema';
import { formatUpdateRequest } from './utils';
const VIEW_INCIDENT_URL = `#incidents`;
export class ResilientConnector extends CaseConnector<
ResilientConfig,
ResilientSecrets,
Incident,
GetIncidentResponse
> {
private urls: {
incidentTypes: string;
incident: string;
comment: string;
severity: string;
};
constructor(
params: ServiceParams<ResilientConfig, ResilientSecrets>,
pushToServiceParamsExtendedSchema: Record<string, Type<unknown>>
) {
super(params, pushToServiceParamsExtendedSchema);
this.urls = {
incidentTypes: `${this.getIncidentFieldsUrl()}/incident_type_ids`,
incident: `${this.getOrgUrl()}/incidents`,
comment: `${this.getOrgUrl()}/incidents/{inc_id}/comments`,
severity: `${this.getIncidentFieldsUrl()}/severity_code`,
};
this.registerSubActions();
}
protected getResponseErrorMessage(error: AxiosError) {
if (!error.response?.status) {
return i18n.UNKNOWN_API_ERROR;
}
if (error.response.status === 401) {
return i18n.UNAUTHORIZED_API_ERROR;
}
return `API Error: ${error.response?.statusText}`;
}
private registerSubActions() {
this.registerSubAction({
name: SUB_ACTION.INCIDENT_TYPES,
method: 'getIncidentTypes',
schema: ExecutorSubActionGetIncidentTypesParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.SEVERITY,
method: 'getSeverity',
schema: ExecutorSubActionGetSeverityParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.FIELDS,
method: 'getFields',
schema: ExecutorSubActionCommonFieldsParamsSchema,
});
}
private getAuthHeaders() {
const token = Buffer.from(
this.secrets.apiKeyId + ':' + this.secrets.apiKeySecret,
'utf8'
).toString('base64');
return { Authorization: `Basic ${token}` };
}
private getOrgUrl() {
const { apiUrl: url, orgId } = this.config;
return `${url}/rest/orgs/${orgId}`;
}
private getIncidentFieldsUrl = () => `${this.getOrgUrl()}/types/incident/fields`;
private getIncidentViewURL(key: string) {
const url = this.config.apiUrl;
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`;
}
public async createIncident(incident: Incident): Promise<ExternalServiceIncidentResponse> {
try {
let data: CreateIncidentData = {
name: incident.name,
discovered_date: Date.now(),
};
if (incident?.description) {
data = {
...data,
description: {
format: 'html',
content: incident.description ?? '',
},
};
}
if (incident?.incidentTypes) {
data = {
...data,
incident_type_ids: incident.incidentTypes.map((id: number | string) => ({
id: Number(id),
})),
};
}
if (incident?.severityCode) {
data = {
...data,
severity_code: { id: Number(incident.severityCode) },
};
}
const res = await this.request({
url: `${this.urls.incident}?text_content_output_format=objects_convert`,
method: 'POST',
data,
headers: this.getAuthHeaders(),
responseSchema: schema.object(
{
id: schema.number(),
create_date: schema.number(),
},
{ unknowns: 'allow' }
),
});
const { id, create_date: createDate } = res.data;
return {
title: `${id}`,
id: `${id}`,
pushedDate: new Date(createDate).toISOString(),
url: this.getIncidentViewURL(id.toString()),
};
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}.`)
);
}
}
public async updateIncident({
incidentId,
incident,
}: UpdateIncidentParams): Promise<ExternalServiceIncidentResponse> {
try {
const latestIncident = await this.getIncident({ id: incidentId });
// Remove null or undefined values. Allowing null values sets the field in IBM Resilient to empty.
const newIncident = omitBy(isNil, incident);
const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident });
const res = await this.request({
method: 'PATCH',
url: `${this.urls.incident}/${incidentId}`,
data,
headers: this.getAuthHeaders(),
responseSchema: schema.object({ success: schema.boolean() }, { unknowns: 'allow' }),
});
if (!res.data.success) {
throw new Error('Error while updating incident');
}
const updatedIncident = await this.getIncident({ id: incidentId });
return {
title: `${updatedIncident.id}`,
id: `${updatedIncident.id}`,
pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(),
url: this.getIncidentViewURL(updatedIncident.id.toString()),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to update incident with id ${incidentId}. Error: ${error.message}.`
)
);
}
}
public async addComment({ incidentId, comment }: { incidentId: string; comment: string }) {
try {
await this.request({
method: 'POST',
url: this.urls.comment.replace('{inc_id}', incidentId),
data: { text: { format: 'text', content: comment } },
headers: this.getAuthHeaders(),
responseSchema: schema.object({}, { unknowns: 'allow' }),
});
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to create comment at incident with id ${incidentId}. Error: ${error.message}.`
)
);
}
}
public async getIncident({ id }: { id: string }): Promise<GetIncidentResponse> {
try {
const res = await this.request({
method: 'GET',
url: `${this.urls.incident}/${id}`,
params: {
text_content_output_format: 'objects_convert',
},
headers: this.getAuthHeaders(),
responseSchema: GetIncidentResponseSchema,
});
return res.data;
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}.`)
);
}
}
public async getIncidentTypes(): Promise<GetIncidentTypesResponse> {
try {
const res = await this.request({
method: 'GET',
url: this.urls.incidentTypes,
headers: this.getAuthHeaders(),
responseSchema: GetIncidentTypesResponseSchema,
});
const incidentTypes = res.data?.values ?? [];
return incidentTypes.map((type: { value: number; label: string }) => ({
id: type.value.toString(),
name: type.label,
}));
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get incident types. Error: ${error.message}.`)
);
}
}
public async getSeverity(): Promise<GetSeverityResponse> {
try {
const res = await this.request({
method: 'GET',
url: this.urls.severity,
headers: this.getAuthHeaders(),
responseSchema: GetSeverityResponseSchema,
});
const severities = res.data?.values ?? [];
return severities.map((type: { value: number; label: string }) => ({
id: type.value.toString(),
name: type.label,
}));
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get severity. Error: ${error.message}.`)
);
}
}
public async getFields() {
try {
const res = await this.request({
method: 'GET',
url: this.getIncidentFieldsUrl(),
headers: this.getAuthHeaders(),
responseSchema: GetCommonFieldsResponseSchema,
});
const fields = res.data.map((field) => {
return {
name: field.name,
input_type: field.input_type,
read_only: field.read_only,
required: field.required,
text: field.text,
};
});
return fields;
} catch (error) {
throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}.`));
}
}
}

View file

@ -43,39 +43,66 @@ export const ExecutorSubActionPushParamsSchema = schema.object({
),
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
externalId: schema.string(),
});
export const PushToServiceIncidentSchema = {
name: schema.string(),
description: schema.nullable(schema.string()),
incidentTypes: schema.nullable(schema.arrayOf(schema.number())),
severityCode: schema.nullable(schema.number()),
};
// Reserved for future implementation
export const ExecutorSubActionCommonFieldsParamsSchema = schema.object({});
export const ExecutorSubActionHandshakeParamsSchema = schema.object({});
export const ExecutorSubActionGetIncidentTypesParamsSchema = schema.object({});
export const ExecutorSubActionGetSeverityParamsSchema = schema.object({});
export const ExecutorParamsSchema = schema.oneOf([
schema.object({
subAction: schema.literal('getFields'),
subActionParams: ExecutorSubActionCommonFieldsParamsSchema,
}),
schema.object({
subAction: schema.literal('getIncident'),
subActionParams: ExecutorSubActionGetIncidentParamsSchema,
}),
schema.object({
subAction: schema.literal('handshake'),
subActionParams: ExecutorSubActionHandshakeParamsSchema,
}),
schema.object({
subAction: schema.literal('pushToService'),
subActionParams: ExecutorSubActionPushParamsSchema,
}),
schema.object({
subAction: schema.literal('incidentTypes'),
subActionParams: ExecutorSubActionGetIncidentTypesParamsSchema,
}),
schema.object({
subAction: schema.literal('severity'),
subActionParams: ExecutorSubActionGetSeverityParamsSchema,
}),
]);
const ArrayOfValuesSchema = schema.arrayOf(
schema.object(
{
value: schema.number(),
label: schema.string(),
},
{ unknowns: 'allow' }
)
);
export const GetIncidentTypesResponseSchema = schema.object(
{
values: ArrayOfValuesSchema,
},
{ unknowns: 'allow' }
);
export const GetSeverityResponseSchema = schema.object(
{
values: ArrayOfValuesSchema,
},
{ unknowns: 'allow' }
);
export const ExternalServiceFieldsSchema = schema.object(
{
input_type: schema.string(),
name: schema.string(),
read_only: schema.boolean(),
required: schema.nullable(schema.string()),
text: schema.string(),
},
{ unknowns: 'allow' }
);
export const GetCommonFieldsResponseSchema = schema.arrayOf(ExternalServiceFieldsSchema);
export const ExternalServiceIncidentResponseSchema = schema.object({
id: schema.string(),
title: schema.string(),
url: schema.string(),
pushedDate: schema.string(),
});
export const GetIncidentResponseSchema = schema.object(
{
id: schema.number(),
inc_last_modified_date: schema.number(),
},
{ unknowns: 'allow' }
);

View file

@ -1,714 +0,0 @@
/*
* 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 axios from 'axios';
import { createExternalService, getValueTextContent, formatUpdateRequest } from './service';
import { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils';
import { ExternalService } from './types';
import { Logger } from '@kbn/core/server';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { incidentTypes, resilientFields, severity } from './mocks';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = request as jest.Mock;
const now = Date.now;
const TIMESTAMP = 1589391874472;
const configurationUtilities = actionsConfigMock.create();
// Incident update makes three calls to the API.
// The function below mocks this calls.
// a) Get the latest incident
// b) Update the incident
// c) Get the updated incident
const mockIncidentUpdate = (withUpdateError = false) => {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: {
format: 'html',
content: 'description',
},
incident_type_ids: [1001, 16, 12],
severity_code: 6,
},
})
);
if (withUpdateError) {
requestMock.mockImplementationOnce(() => {
throw new Error('An error has occurred');
});
} else {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
success: true,
id: '1',
inc_last_modified_date: 1589391874472,
},
})
);
}
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title_updated',
description: {
format: 'html',
content: 'desc_updated',
},
inc_last_modified_date: 1589391874472,
},
})
);
};
describe('IBM Resilient service', () => {
let service: ExternalService;
beforeAll(() => {
service = createExternalService(
{
// The trailing slash at the end of the url is intended.
// All API calls need to have the trailing slash removed.
config: { apiUrl: 'https://resilient.elastic.co/', orgId: '201' },
secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' },
},
logger,
configurationUtilities
);
});
afterAll(() => {
Date.now = now;
});
beforeEach(() => {
jest.resetAllMocks();
Date.now = jest.fn().mockReturnValue(TIMESTAMP);
});
describe('getValueTextContent', () => {
test('transforms correctly', () => {
expect(getValueTextContent('name', 'title')).toEqual({
text: 'title',
});
});
test('transforms correctly the description', () => {
expect(getValueTextContent('description', 'desc')).toEqual({
textarea: {
format: 'html',
content: 'desc',
},
});
});
});
describe('formatUpdateRequest', () => {
test('transforms correctly', () => {
const oldIncident = { name: 'title', description: 'desc' };
const newIncident = { name: 'title_updated', description: 'desc_updated' };
expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({
changes: [
{
field: { name: 'name' },
old_value: { text: 'title' },
new_value: { text: 'title_updated' },
},
{
field: { name: 'description' },
old_value: {
textarea: {
format: 'html',
content: 'desc',
},
},
new_value: {
textarea: {
format: 'html',
content: 'desc_updated',
},
},
},
],
});
});
});
describe('createExternalService', () => {
test('throws without url', () => {
expect(() =>
createExternalService(
{
config: { apiUrl: null, orgId: '201' },
secrets: { apiKeyId: 'token', apiKeySecret: 'secret' },
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws without orgId', () => {
expect(() =>
createExternalService(
{
config: { apiUrl: 'test.com', orgId: null },
secrets: { apiKeyId: 'token', apiKeySecret: 'secret' },
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws without username', () => {
expect(() =>
createExternalService(
{
config: { apiUrl: 'test.com', orgId: '201' },
secrets: { apiKeyId: '', apiKeySecret: 'secret' },
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws without password', () => {
expect(() =>
createExternalService(
{
config: { apiUrl: 'test.com', orgId: '201' },
secrets: { apiKeyId: '', apiKeySecret: undefined },
},
logger,
configurationUtilities
)
).toThrow();
});
});
describe('getIncident', () => {
test('it returns the incident correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
name: '1',
description: {
format: 'html',
content: 'description',
},
},
})
);
const res = await service.getIncident('1');
expect(res).toEqual({ id: '1', name: '1', description: 'description' });
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: { id: '1' },
})
);
await service.getIncident('1');
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1',
params: {
text_content_output_format: 'objects_convert',
},
configurationUtilities,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
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'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.getIncident('1')).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
});
describe('createIncident', () => {
const incident = {
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
};
test('it creates the incident correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: 'description',
discovered_date: 1589391874472,
create_date: 1589391874472,
},
})
);
const res = await service.createIncident(incident);
expect(res).toEqual({
title: '1',
id: '1',
pushedDate: '2020-05-13T17:44:34.472Z',
url: 'https://resilient.elastic.co/#incidents/1',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: 'description',
discovered_date: 1589391874472,
create_date: 1589391874472,
},
})
);
await service.createIncident(incident);
expect(requestMock).toHaveBeenCalledWith({
axios,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents?text_content_output_format=objects_convert',
logger,
method: 'post',
configurationUtilities,
data: {
name: 'title',
description: {
format: 'html',
content: 'desc',
},
discovered_date: TIMESTAMP,
incident_type_ids: [{ id: 1001 }],
severity_code: { id: 6 },
},
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(
service.createIncident({
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
})
).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create incident. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
test('it should throw if the required attributes are not there', async () => {
requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } }));
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create incident. Error: Response is missing at least one of the expected fields: id,create_date.'
);
});
});
describe('updateIncident', () => {
const req = {
incidentId: '1',
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
};
test('it updates the incident correctly', async () => {
mockIncidentUpdate();
const res = await service.updateIncident(req);
expect(res).toEqual({
title: '1',
id: '1',
pushedDate: '2020-05-13T17:44:34.472Z',
url: 'https://resilient.elastic.co/#incidents/1',
});
});
test('it should call request with correct arguments', async () => {
mockIncidentUpdate();
await service.updateIncident({
incidentId: '1',
incident: {
name: 'title_updated',
description: 'desc_updated',
incidentTypes: [1001],
severityCode: 5,
},
});
// Incident update makes three calls to the API.
// The second call to the API is the update call.
expect(requestMock.mock.calls[1][0]).toEqual({
axios,
logger,
method: 'patch',
configurationUtilities,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1',
data: {
changes: [
{
field: { name: 'name' },
old_value: { text: 'title' },
new_value: { text: 'title_updated' },
},
{
field: { name: 'description' },
old_value: {
textarea: {
content: 'description',
format: 'html',
},
},
new_value: {
textarea: {
content: 'desc_updated',
format: 'html',
},
},
},
{
field: {
name: 'incident_type_ids',
},
old_value: {
ids: [1001, 16, 12],
},
new_value: {
ids: [1001],
},
},
{
field: {
name: 'severity_code',
},
old_value: {
id: 6,
},
new_value: {
id: 5,
},
},
],
},
});
});
test('it should throw an error', async () => {
mockIncidentUpdate(true);
await expect(service.updateIncident(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred'
);
});
test('it should throw if the request is not a JSON', async () => {
// get incident request
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
name: 'title',
description: {
format: 'html',
content: 'description',
},
incident_type_ids: [1001, 16, 12],
severity_code: 6,
},
})
);
// update incident request
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.updateIncident(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to update incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json'
);
});
});
describe('createComment', () => {
const req = {
incidentId: '1',
comment: {
comment: 'comment',
commentId: 'comment-1',
},
};
test('it creates the comment correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
create_date: 1589391874472,
},
})
);
const res = await service.createComment(req);
expect(res).toEqual({
commentId: 'comment-1',
pushedDate: '2020-05-13T17:44:34.472Z',
externalCommentId: '1',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
create_date: 1589391874472,
},
})
);
await service.createComment(req);
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
method: 'post',
configurationUtilities,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments',
data: {
text: {
content: 'comment',
format: 'text',
},
},
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.createComment(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.createComment(req)).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
});
describe('getIncidentTypes', () => {
test('it creates the incident correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
values: incidentTypes,
},
})
);
const res = await service.getIncidentTypes();
expect(res).toEqual([
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
]);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.getIncidentTypes()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.getIncidentTypes()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
});
describe('getSeverity', () => {
test('it creates the incident correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
values: severity,
},
})
);
const res = await service.getSeverity();
expect(res).toEqual([
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
]);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.getSeverity()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.getSeverity()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get severity. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
});
describe('getFields', () => {
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: resilientFields,
})
);
await service.getFields();
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://resilient.elastic.co/rest/orgs/201/types/incident/fields',
});
});
test('it returns common fields correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: resilientFields,
})
);
const res = await service.getFields();
expect(res).toEqual(resilientFields);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.getFields()).rejects.toThrow(
'Unable to get fields. Error: An error has occurred'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.getFields()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
});
});

View file

@ -1,364 +0,0 @@
/*
* 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 axios from 'axios';
import { omitBy, isNil } from 'lodash/fp';
import { Logger } from '@kbn/core/server';
import {
getErrorMessage,
request,
throwIfResponseIsNotValid,
} from '@kbn/actions-plugin/server/lib/axios_utils';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
import {
ExternalServiceCredentials,
ExternalService,
ExternalServiceParams,
CreateCommentParams,
UpdateIncidentParams,
CreateIncidentParams,
CreateIncidentData,
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
UpdateIncidentRequest,
GetValueTextContentResponse,
} from './types';
import * as i18n from './translations';
const VIEW_INCIDENT_URL = `#incidents`;
export const getValueTextContent = (
field: string,
value: string | number | number[]
): GetValueTextContentResponse => {
if (field === 'description') {
return {
textarea: {
format: 'html',
content: value as string,
},
};
}
if (field === 'incidentTypes') {
return {
ids: value as number[],
};
}
if (field === 'severityCode') {
return {
id: value as number,
};
}
return {
text: value as string,
};
};
export const formatUpdateRequest = ({
oldIncident,
newIncident,
}: ExternalServiceParams): UpdateIncidentRequest => {
return {
changes: Object.keys(newIncident as Record<string, unknown>).map((key) => {
let name = key;
if (key === 'incidentTypes') {
name = 'incident_type_ids';
}
if (key === 'severityCode') {
name = 'severity_code';
}
return {
field: { name },
// TODO: Fix ugly casting
old_value: getValueTextContent(
key,
(oldIncident as Record<string, unknown>)[name] as string
),
new_value: getValueTextContent(
key,
(newIncident as Record<string, unknown>)[key] as string
),
};
}),
};
};
export const createExternalService = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
): ExternalService => {
const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType;
const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType;
if (!url || !orgId || !apiKeyId || !apiKeySecret) {
throw Error(`[Action]${i18n.NAME}: Wrong configuration.`);
}
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const orgUrl = `${urlWithoutTrailingSlash}/rest/orgs/${orgId}`;
const incidentUrl = `${orgUrl}/incidents`;
const commentUrl = `${incidentUrl}/{inc_id}/comments`;
const incidentFieldsUrl = `${orgUrl}/types/incident/fields`;
const incidentTypesUrl = `${incidentFieldsUrl}/incident_type_ids`;
const severityUrl = `${incidentFieldsUrl}/severity_code`;
const axiosInstance = axios.create({
auth: { username: apiKeyId, password: apiKeySecret },
});
const getIncidentViewURL = (key: string) => {
return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`;
};
const getCommentsURL = (incidentId: string) => {
return commentUrl.replace('{inc_id}', incidentId);
};
const getIncident = async (id: string) => {
try {
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}/${id}`,
logger,
params: {
text_content_output_format: 'objects_convert',
},
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
});
return { ...res.data, description: res.data.description?.content ?? '' };
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}.`)
);
}
};
const createIncident = async ({ incident }: CreateIncidentParams) => {
let data: CreateIncidentData = {
name: incident.name,
discovered_date: Date.now(),
};
if (incident.description) {
data = {
...data,
description: {
format: 'html',
content: incident.description ?? '',
},
};
}
if (incident.incidentTypes) {
data = {
...data,
incident_type_ids: incident.incidentTypes.map((id) => ({ id })),
};
}
if (incident.severityCode) {
data = {
...data,
severity_code: { id: incident.severityCode },
};
}
try {
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}?text_content_output_format=objects_convert`,
method: 'post',
logger,
data,
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
requiredAttributesToBeInTheResponse: ['id', 'create_date'],
});
return {
title: `${res.data.id}`,
id: `${res.data.id}`,
pushedDate: new Date(res.data.create_date).toISOString(),
url: getIncidentViewURL(res.data.id),
};
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}.`)
);
}
};
const updateIncident = async ({ incidentId, incident }: UpdateIncidentParams) => {
try {
const latestIncident = await getIncident(incidentId);
// Remove null or undefined values. Allowing null values sets the field in IBM Resilient to empty.
const newIncident = omitBy(isNil, incident);
const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident });
const res = await request({
axios: axiosInstance,
method: 'patch',
url: `${incidentUrl}/${incidentId}`,
logger,
data,
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
});
if (!res.data.success) {
throw new Error(res.data.message);
}
const updatedIncident = await getIncident(incidentId);
return {
title: `${updatedIncident.id}`,
id: `${updatedIncident.id}`,
pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(),
url: getIncidentViewURL(updatedIncident.id),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to update incident with id ${incidentId}. Error: ${error.message}`
)
);
}
};
const createComment = async ({ incidentId, comment }: CreateCommentParams) => {
try {
const res = await request({
axios: axiosInstance,
method: 'post',
url: getCommentsURL(incidentId),
logger,
data: { text: { format: 'text', content: comment.comment } },
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
});
return {
commentId: comment.commentId,
externalCommentId: res.data.id,
pushedDate: new Date(res.data.create_date).toISOString(),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to create comment at incident with id ${incidentId}. Error: ${error.message}.`
)
);
}
};
const getIncidentTypes = async () => {
try {
const res = await request({
axios: axiosInstance,
method: 'get',
url: incidentTypesUrl,
logger,
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
});
const incidentTypes = res.data?.values ?? [];
return incidentTypes.map((type: { value: string; label: string }) => ({
id: type.value,
name: type.label,
}));
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get incident types. Error: ${error.message}.`)
);
}
};
const getSeverity = async () => {
try {
const res = await request({
axios: axiosInstance,
method: 'get',
url: severityUrl,
logger,
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
});
const incidentTypes = res.data?.values ?? [];
return incidentTypes.map((type: { value: string; label: string }) => ({
id: type.value,
name: type.label,
}));
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get severity. Error: ${error.message}.`)
);
}
};
const getFields = async () => {
try {
const res = await request({
axios: axiosInstance,
url: incidentFieldsUrl,
logger,
configurationUtilities,
});
throwIfResponseIsNotValid({
res,
});
return res.data ?? [];
} catch (error) {
throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}.`));
}
};
return {
createComment,
createIncident,
getFields,
getIncident,
getIncidentTypes,
getSeverity,
updateIncident,
};
};

View file

@ -18,3 +18,14 @@ export const ALLOWED_HOSTS_ERROR = (message: string) =>
message,
},
});
export const UNKNOWN_API_ERROR = i18n.translate('xpack.stackConnectors.resilient.unknownError', {
defaultMessage: 'Unknown API Error',
});
export const UNAUTHORIZED_API_ERROR = i18n.translate(
'xpack.stackConnectors.resilient.unauthorizedError',
{
defaultMessage: 'Unauthorized API Error',
}
);

View file

@ -8,34 +8,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TypeOf } from '@kbn/config-schema';
import { Logger } from '@kbn/core/server';
import { ValidatorServices } from '@kbn/actions-plugin/server/types';
import {
ExecutorParamsSchema,
ExecutorSubActionCommonFieldsParamsSchema,
ExecutorSubActionGetIncidentParamsSchema,
ExecutorSubActionGetIncidentTypesParamsSchema,
ExecutorSubActionGetSeverityParamsSchema,
ExecutorSubActionHandshakeParamsSchema,
ExecutorSubActionPushParamsSchema,
ExternalIncidentServiceConfigurationSchema,
ExternalIncidentServiceSecretConfigurationSchema,
ExternalServiceIncidentResponseSchema,
GetIncidentResponseSchema,
} from './schema';
export type ResilientPublicConfigurationType = TypeOf<
typeof ExternalIncidentServiceConfigurationSchema
>;
export type ResilientSecretConfigurationType = TypeOf<
typeof ExternalIncidentServiceSecretConfigurationSchema
>;
export type ExecutorSubActionCommonFieldsParams = TypeOf<
typeof ExecutorSubActionCommonFieldsParamsSchema
>;
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export interface ExternalServiceCredentials {
config: Record<string, unknown>;
secrets: Record<string, unknown>;
@ -46,14 +27,9 @@ export interface ExternalServiceValidation {
secrets: (secrets: any, validatorServices: ValidatorServices) => void;
}
export interface ExternalServiceIncidentResponse {
id: string;
title: string;
url: string;
pushedDate: string;
}
export type GetIncidentTypesResponse = Array<{ id: string; name: string }>;
export type GetSeverityResponse = Array<{ id: string; name: string }>;
export type ExternalServiceParams = Record<string, unknown>;
export interface ExternalServiceFields {
input_type: string;
name: string;
@ -65,104 +41,11 @@ export type GetCommonFieldsResponse = ExternalServiceFields[];
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export interface CreateIncidentParams {
incident: Incident;
}
export interface UpdateIncidentParams {
incidentId: string;
incident: Incident;
}
export interface CreateCommentParams {
incidentId: string;
comment: SimpleComment;
}
export type GetIncidentTypesResponse = Array<{ id: string; name: string }>;
export type GetSeverityResponse = Array<{ id: string; name: string }>;
export interface ExternalService {
createComment: (params: CreateCommentParams) => Promise<ExternalServiceCommentResponse>;
createIncident: (params: CreateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
getFields: () => Promise<GetCommonFieldsResponse>;
getIncident: (id: string) => Promise<ExternalServiceParams | undefined>;
getIncidentTypes: () => Promise<GetIncidentTypesResponse>;
getSeverity: () => Promise<GetSeverityResponse>;
updateIncident: (params: UpdateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
export type ExecutorSubActionGetIncidentTypesParams = TypeOf<
typeof ExecutorSubActionGetIncidentTypesParamsSchema
>;
export type ExecutorSubActionGetSeverityParams = TypeOf<
typeof ExecutorSubActionGetSeverityParamsSchema
>;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
}
export type ExecutorSubActionGetIncidentParams = TypeOf<
typeof ExecutorSubActionGetIncidentParamsSchema
>;
export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
logger: Logger;
}
export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionGetIncidentParams;
}
export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionHandshakeParams;
}
export interface GetCommonFieldsHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionCommonFieldsParams;
}
export interface GetIncidentTypesHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetIncidentTypesParams;
}
export interface GetSeverityHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetSeverityParams;
}
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export interface ExternalServiceApi {
getFields: (args: GetCommonFieldsHandlerArgs) => Promise<GetCommonFieldsResponse>;
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
incidentTypes: (args: GetIncidentTypesHandlerArgs) => Promise<GetIncidentTypesResponse>;
severity: (args: GetSeverityHandlerArgs) => Promise<GetSeverityResponse>;
}
export type ResilientExecutorResultData =
| PushToServiceResponse
| GetCommonFieldsResponse
| GetIncidentTypesResponse
| GetSeverityResponse;
export interface UpdateFieldText {
text: string;
}
export interface UpdateFieldText {
text: string;
}
@ -170,7 +53,6 @@ export interface UpdateFieldText {
export interface UpdateIdsField {
ids: number[];
}
export interface UpdateIdField {
id: number;
}
@ -202,12 +84,11 @@ export interface CreateIncidentData {
incident_type_ids?: Array<{ id: number }>;
severity_code?: { id: number };
}
export interface SimpleComment {
comment: string;
commentId: string;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export type ResilientConfig = TypeOf<typeof ExternalIncidentServiceConfigurationSchema>;
export type ResilientSecrets = TypeOf<typeof ExternalIncidentServiceSecretConfigurationSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type ExternalServiceIncidentResponse = TypeOf<typeof ExternalServiceIncidentResponseSchema>;
export type GetIncidentResponse = TypeOf<typeof GetIncidentResponseSchema>;

View file

@ -0,0 +1,106 @@
/*
* 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 { formatUpdateRequest, getValueTextContent } from './utils';
describe('utils', () => {
describe('getValueTextContent', () => {
test('transforms name correctly', () => {
expect(getValueTextContent('name', 'title')).toEqual({
text: 'title',
});
});
test('transforms correctly the description', () => {
expect(getValueTextContent('description', 'desc')).toEqual({
textarea: {
format: 'html',
content: 'desc',
},
});
});
test('transforms correctly the severityCode', () => {
expect(getValueTextContent('severityCode', 6)).toEqual({
id: 6,
});
});
test('transforms correctly the severityCode as string', () => {
expect(getValueTextContent('severityCode', '6')).toEqual({
id: 6,
});
});
test('transforms correctly the incidentTypes', () => {
expect(getValueTextContent('incidentTypes', [1101, 12])).toEqual({
ids: [1101, 12],
});
});
test('transforms default correctly', () => {
expect(getValueTextContent('randomField', 'this is random')).toEqual({
text: 'this is random',
});
});
});
describe('formatUpdateRequest', () => {
test('transforms correctly', () => {
const oldIncident = {
name: 'title',
description: { format: 'html', content: 'desc' },
severity_code: '5',
incident_type_ids: [12, 16],
};
const newIncident = {
name: 'title_updated',
description: 'desc_updated',
severityCode: '6',
incidentTypes: [12, 16, 1001],
};
expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({
changes: [
{
field: { name: 'name' },
old_value: { text: 'title' },
new_value: { text: 'title_updated' },
},
{
field: { name: 'description' },
old_value: {
textarea: {
format: 'html',
content: 'desc',
},
},
new_value: {
textarea: {
format: 'html',
content: 'desc_updated',
},
},
},
{
field: { name: 'severity_code' },
old_value: {
id: 5,
},
new_value: { id: 6 },
},
{
field: { name: 'incident_type_ids' },
old_value: { ids: [12, 16] },
new_value: {
ids: [12, 16, 1001],
},
},
],
});
});
});
});

View file

@ -0,0 +1,75 @@
/*
* 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 { isArray } from 'lodash';
import { GetValueTextContentResponse, UpdateIncidentRequest } from './types';
export const getValueTextContent = (
field: string,
value: string | number | number[]
): GetValueTextContentResponse => {
if (field === 'description') {
return {
textarea: {
format: 'html',
content: value.toString(),
},
};
}
if (field === 'incidentTypes') {
if (isArray(value)) {
return { ids: value.map((item) => Number(item)) };
}
return {
ids: [Number(value)],
};
}
if (field === 'severityCode') {
return {
id: Number(value),
};
}
return {
text: value.toString(),
};
};
export const formatUpdateRequest = ({
oldIncident,
newIncident,
}: {
oldIncident: Record<string, unknown>;
newIncident: Record<string, unknown>;
}): UpdateIncidentRequest => {
return {
changes: Object.keys(newIncident).map((key) => {
let name = key;
if (key === 'incidentTypes') {
name = 'incident_type_ids';
}
if (key === 'severityCode') {
name = 'severity_code';
}
return {
field: { name },
old_value: getValueTextContent(
key,
name === 'description'
? (oldIncident as { description: { content: string } }).description.content
: (oldIncident[name] as string | number | number[])
),
new_value: getValueTextContent(key, newIncident[key] as string),
};
}),
};
};

View file

@ -1,37 +0,0 @@
/*
* 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 { ValidatorServices } from '@kbn/actions-plugin/server/types';
import {
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ExternalServiceValidation,
} from './types';
import * as i18n from './translations';
export const validateCommonConfig = (
configObject: ResilientPublicConfigurationType,
validatorServices: ValidatorServices
) => {
const { configurationUtilities } = validatorServices;
try {
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
} catch (allowedListError) {
throw new Error(i18n.ALLOWED_HOSTS_ERROR(allowedListError.message));
}
};
export const validateCommonSecrets = (
secrets: ResilientSecretConfigurationType,
validatorServices: ValidatorServices
) => {};
export const validate: ExternalServiceValidation = {
config: validateCommonConfig,
secrets: validateCommonSecrets,
};

View file

@ -25,7 +25,7 @@ describe('Stack Connectors Plugin', () => {
it('should register built in connector types', () => {
const actionsSetup = actionsMock.createSetup();
plugin.setup(coreSetup, { actions: actionsSetup });
expect(actionsSetup.registerType).toHaveBeenCalledTimes(17);
expect(actionsSetup.registerType).toHaveBeenCalledTimes(16);
expect(actionsSetup.registerType).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@ -119,26 +119,19 @@ describe('Stack Connectors Plugin', () => {
);
expect(actionsSetup.registerType).toHaveBeenNthCalledWith(
15,
expect.objectContaining({
id: '.resilient',
name: 'IBM Resilient',
})
);
expect(actionsSetup.registerType).toHaveBeenNthCalledWith(
16,
expect.objectContaining({
id: '.teams',
name: 'Microsoft Teams',
})
);
expect(actionsSetup.registerType).toHaveBeenNthCalledWith(
17,
16,
expect.objectContaining({
id: '.torq',
name: 'Torq',
})
);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(6);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(7);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@ -174,6 +167,13 @@ describe('Stack Connectors Plugin', () => {
name: 'D3 Security',
})
);
expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith(
6,
expect.objectContaining({
id: '.resilient',
name: 'IBM Resilient',
})
);
});
});
});

View file

@ -32,8 +32,6 @@ export type {
ServiceNowActionParams,
JiraConnectorTypeId,
JiraActionParams,
ResilientConnectorTypeId,
ResilientActionParams,
TeamsConnectorTypeId,
TeamsActionParams,
} from './connector_types';

View file

@ -51,12 +51,13 @@ export default function resilientTest({ getService }: FtrProviderContext) {
it('should return 403 when creating a resilient action', async () => {
await supertest
.post('/api/actions/action')
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A resilient action',
actionTypeId: '.resilient',
connector_type_id: '.resilient',
config: {
...mockResilient.config,
apiUrl: resilientSimulatorURL,
},
secrets: mockResilient.secrets,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import http from 'http';
import {
RequestHandlerContext,
KibanaRequest,
@ -12,6 +13,55 @@ import {
IKibanaResponse,
IRouter,
} from '@kbn/core/server';
import { ProxyArgs, Simulator } from './simulator';
export const resilientFailedResponse = {
errors: {
message: 'failed',
},
};
export class ResilientSimulator extends Simulator {
private readonly returnError: boolean;
constructor({ returnError = false, proxy }: { returnError?: boolean; proxy?: ProxyArgs }) {
super(proxy);
this.returnError = returnError;
}
public async handler(
request: http.IncomingMessage,
response: http.ServerResponse,
data: Record<string, unknown>
) {
if (this.returnError) {
return ResilientSimulator.sendErrorResponse(response);
}
return ResilientSimulator.sendResponse(request, response);
}
private static sendResponse(request: http.IncomingMessage, response: http.ServerResponse) {
response.statusCode = 202;
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify(
{
id: '123',
create_date: 1589391874472,
},
null,
4
)
);
}
private static sendErrorResponse(response: http.ServerResponse) {
response.statusCode = 422;
response.setHeader('Content-Type', 'application/json;charset=UTF-8');
response.end(JSON.stringify(resilientFailedResponse, null, 4));
}
}
export function initPlugin(router: IRouter, path: string) {
router.post(

View file

@ -5,21 +5,15 @@
* 2.0.
*/
import httpProxy from 'http-proxy';
import expect from '@kbn/expect';
import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '@kbn/actions-simulators-plugin/server/plugin';
import { TaskErrorSource } from '@kbn/task-manager-plugin/common';
import { ResilientSimulator } from '@kbn/actions-simulators-plugin/server/resilient_simulation';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function resilientTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const configService = getService('config');
const mockResilient = {
@ -51,17 +45,25 @@ export default function resilientTest({ getService }: FtrProviderContext) {
},
};
let resilientSimulatorURL: string = '<could not determine kibana url>';
describe('IBM Resilient', () => {
before(() => {
resilientSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT)
);
});
describe('IBM Resilient - Action Creation', () => {
it('should return 200 when creating a ibm resilient action successfully', async () => {
const simulator = new ResilientSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let resilientSimulatorURL: string = '<could not determine kibana url>';
before(async () => {
resilientSimulatorURL = await simulator.start();
});
after(() => {
simulator.close();
});
it('should return 200 when creating a ibm resilient connector successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
@ -168,7 +170,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: error configuring connector action: target url "http://resilient.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
'error validating action type config: error validating url: target url "http://resilient.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
});
});
});
@ -198,37 +200,28 @@ export default function resilientTest({ getService }: FtrProviderContext) {
});
describe('IBM Resilient - Executor', () => {
let simulatedActionId: string;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A ibm resilient simulator',
connector_type_id: '.resilient',
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
},
secrets: mockResilient.secrets,
});
simulatedActionId = body.id;
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
describe('Validation', () => {
const simulator = new ResilientSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let resilientActionId: string;
let resilientSimulatorURL: string = '<could not determine kibana url>';
before(async () => {
resilientSimulatorURL = await simulator.start();
resilientActionId = await createConnector(resilientSimulatorURL);
});
after(() => {
simulator.close();
});
it('should handle failing with a simulated success without action', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
@ -241,25 +234,25 @@ export default function resilientTest({ getService }: FtrProviderContext) {
'errorSource',
'connector_id',
]);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.connector_id).to.eql(resilientActionId);
expect(resp.body.status).to.eql('error');
});
});
it('should handle failing with a simulated success without unsupported action', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'non-supported' },
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
connector_id: resilientActionId,
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 [incidentTypes]\n- [5.subAction]: expected value to equal [severity]',
retry: true,
message: 'an error occurred while running the action',
service_message: `Sub action "non-supported" is not registered. Connector id: ${resilientActionId}. Connector name: IBM Resilient. Connector type: .resilient`,
errorSource: TaskErrorSource.FRAMEWORK,
});
});
@ -267,18 +260,19 @@ export default function resilientTest({ getService }: FtrProviderContext) {
it('should handle failing with a simulated success without subActionParams', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'pushToService' },
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
connector_id: resilientActionId,
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.name]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Request validation failed (Error: [incident.name]: expected value of type [string] but got [undefined])',
errorSource: TaskErrorSource.FRAMEWORK,
});
});
@ -286,7 +280,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
it('should handle failing with a simulated success without title', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
@ -301,19 +295,20 @@ export default function resilientTest({ getService }: FtrProviderContext) {
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
connector_id: resilientActionId,
status: 'error',
retry: false,
retry: true,
message: 'an error occurred while running the action',
errorSource: TaskErrorSource.FRAMEWORK,
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.name]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]',
service_message:
'Request validation failed (Error: [incident.name]: expected value of type [string] but got [undefined])',
});
});
});
it('should handle failing with a simulated success without commentId', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
@ -329,23 +324,24 @@ export default function resilientTest({ getService }: FtrProviderContext) {
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
connector_id: resilientActionId,
status: 'error',
retry: false,
retry: true,
message: 'an error occurred while running the action',
errorSource: TaskErrorSource.FRAMEWORK,
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 [incidentTypes]\n- [5.subAction]: expected value to equal [severity]',
service_message:
'Request validation failed (Error: [comments]: types that failed validation:\n- [comments.0.0.commentId]: expected value of type [string] but got [undefined]\n- [comments.1]: expected value to equal [null])',
});
});
});
it('should handle failing with a simulated success without comment message', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockResilient.params,
subAction: 'pushToService',
subActionParams: {
incident: {
...mockResilient.params.subActionParams.incident,
@ -357,21 +353,40 @@ export default function resilientTest({ getService }: FtrProviderContext) {
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
connector_id: resilientActionId,
status: 'error',
retry: false,
retry: true,
message: 'an error occurred while running the action',
errorSource: TaskErrorSource.FRAMEWORK,
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 [incidentTypes]\n- [5.subAction]: expected value to equal [severity]',
service_message:
'Request validation failed (Error: [comments]: types that failed validation:\n- [comments.0.0.comment]: expected value of type [string] but got [undefined]\n- [comments.1]: expected value to equal [null])',
});
});
});
});
describe('Execution', () => {
const simulator = new ResilientSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let simulatorUrl: string;
let resilientActionId: string;
before(async () => {
simulatorUrl = await simulator.start();
resilientActionId = await createConnector(simulatorUrl);
});
after(() => {
simulator.close();
});
it('should handle creating an incident without comments', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.post(`/api/actions/connector/${resilientActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
@ -384,25 +399,33 @@ export default function resilientTest({ getService }: FtrProviderContext) {
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(body).to.eql({
status: 'ok',
connector_id: simulatedActionId,
connector_id: resilientActionId,
data: {
id: '123',
title: '123',
pushedDate: '2020-05-13T17:44:34.472Z',
url: `${resilientSimulatorURL}/#incidents/123`,
url: `${simulatorUrl}/#incidents/123`,
},
});
});
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
const createConnector = async (url: string) => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A resilient action',
connector_type_id: '.resilient',
config: { ...mockResilient.config, apiUrl: url },
secrets: mockResilient.secrets,
})
.expect(200);
return body.id;
};
});
});
}