[Security Solution][Case] ServiceNow SIR Connector (#88655)

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2021-02-09 12:28:43 +02:00 committed by GitHub
parent 7b5d62fd55
commit a0d4b04155
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
174 changed files with 4861 additions and 2838 deletions

View file

@ -16,6 +16,7 @@ describe('api', () => {
beforeEach(() => {
externalService = externalServiceMock.create();
jest.clearAllMocks();
});
describe('create incident', () => {
@ -26,6 +27,7 @@ describe('api', () => {
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(res).toEqual({
@ -57,6 +59,7 @@ describe('api', () => {
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(res).toEqual({
@ -77,6 +80,7 @@ describe('api', () => {
params,
secrets: { username: 'elastic', password: 'elastic' },
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(externalService.createIncident).toHaveBeenCalledWith({
@ -99,6 +103,7 @@ describe('api', () => {
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(externalService.updateIncident).toHaveBeenCalledTimes(2);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
@ -125,6 +130,41 @@ describe('api', () => {
incidentId: 'incident-1',
});
});
test('it post comments to different comment field key', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({
externalService,
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
});
expect(externalService.updateIncident).toHaveBeenCalledTimes(2);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
incident: {
severity: '1',
urgency: '2',
impact: '3',
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-1',
});
expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
incident: {
severity: '1',
urgency: '2',
impact: '3',
work_notes: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-1',
});
});
});
describe('update incident', () => {
@ -134,6 +174,7 @@ describe('api', () => {
params: apiParams,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(res).toEqual({
@ -161,6 +202,7 @@ describe('api', () => {
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(res).toEqual({
@ -178,6 +220,7 @@ describe('api', () => {
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
@ -200,6 +243,7 @@ describe('api', () => {
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
});
expect(externalService.updateIncident).toHaveBeenCalledTimes(3);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
@ -225,6 +269,40 @@ describe('api', () => {
incidentId: 'incident-2',
});
});
test('it post comments to different comment field key', async () => {
const params = { ...apiParams };
await api.pushToService({
externalService,
params,
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
});
expect(externalService.updateIncident).toHaveBeenCalledTimes(3);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
incident: {
severity: '1',
urgency: '2',
impact: '3',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-3',
});
expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
incident: {
severity: '1',
urgency: '2',
impact: '3',
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-2',
});
});
});
describe('getFields', () => {

View file

@ -25,6 +25,7 @@ const pushToServiceHandler = async ({
externalService,
params,
secrets,
commentFieldKey,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const { comments } = params;
let res: PushToServiceResponse;
@ -53,7 +54,7 @@ const pushToServiceHandler = async ({
incidentId: res.id,
incident: {
...incident,
comments: currentComment.comment,
[commentFieldKey]: currentComment.comment,
},
});
res.comments = [

View file

@ -0,0 +1,120 @@
/*
* 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 { actionsMock } from '../../mocks';
import { createActionTypeRegistry } from '../index.test';
import {
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse,
} from './types';
import {
ServiceNowActionType,
ServiceNowITSMActionTypeId,
ServiceNowSIRActionTypeId,
ServiceNowActionTypeExecutorOptions,
} from '.';
import { api } from './api';
jest.mock('./api', () => ({
api: {
getChoices: jest.fn(),
getFields: jest.fn(),
getIncident: jest.fn(),
handshake: jest.fn(),
pushToService: jest.fn(),
},
}));
const services = actionsMock.createServices();
describe('ServiceNow', () => {
const config = { apiUrl: 'https://instance.com' };
const secrets = { username: 'username', password: 'password' };
const params = {
subAction: 'pushToService',
subActionParams: {
incident: {
short_description: 'An incident',
description: 'This is serious',
},
},
};
beforeEach(() => {
(api.pushToService as jest.Mock).mockResolvedValue({ id: 'some-id' });
});
describe('ServiceNow ITSM', () => {
let actionType: ServiceNowActionType;
beforeAll(() => {
const { actionTypeRegistry } = createActionTypeRegistry();
actionType = actionTypeRegistry.get<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
>(ServiceNowITSMActionTypeId);
});
describe('execute()', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('it pass the correct comment field key', async () => {
const actionId = 'some-action-id';
const executorOptions = ({
actionId,
config,
secrets,
params,
services,
} as unknown) as ServiceNowActionTypeExecutorOptions;
await actionType.executor(executorOptions);
expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe('comments');
});
});
});
describe('ServiceNow SIR', () => {
let actionType: ServiceNowActionType;
beforeAll(() => {
const { actionTypeRegistry } = createActionTypeRegistry();
actionType = actionTypeRegistry.get<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
>(ServiceNowSIRActionTypeId);
});
describe('execute()', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('it pass the correct comment field key', async () => {
const actionId = 'some-action-id';
const executorOptions = ({
actionId,
config,
secrets,
params,
services,
} as unknown) as ServiceNowActionTypeExecutorOptions;
await actionType.executor(executorOptions);
expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe(
'work_notes'
);
});
});
});
});

View file

@ -47,15 +47,21 @@ const serviceNowSIRTable = 'sn_si_incident';
export const ServiceNowITSMActionTypeId = '.servicenow';
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
// action type definition
export function getServiceNowITSMActionType(
params: GetActionTypeParams
): ActionType<
export type ServiceNowActionType = ActionType<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
> {
>;
export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams
>;
// action type definition
export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType {
const { logger, configurationUtilities } = params;
return {
id: ServiceNowITSMActionTypeId,
@ -74,14 +80,7 @@ export function getServiceNowITSMActionType(
};
}
export function getServiceNowSIRActionType(
params: GetActionTypeParams
): ActionType<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
> {
export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType {
const { logger, configurationUtilities } = params;
return {
id: ServiceNowSIRActionTypeId,
@ -96,7 +95,12 @@ export function getServiceNowSIRActionType(
}),
params: ExecutorParamsSchemaSIR,
},
executor: curry(executor)({ logger, configurationUtilities, table: serviceNowSIRTable }),
executor: curry(executor)({
logger,
configurationUtilities,
table: serviceNowSIRTable,
commentFieldKey: 'work_notes',
}),
};
}
@ -107,12 +111,14 @@ async function executor(
logger,
configurationUtilities,
table,
}: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; table: string },
execOptions: ActionTypeExecutorOptions<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams
>
commentFieldKey = 'comments',
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
table: string;
commentFieldKey?: string;
},
execOptions: ServiceNowActionTypeExecutorOptions
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params;
@ -147,6 +153,7 @@ async function executor(
params: pushToServiceParams,
secrets,
logger,
commentFieldKey,
});
logger.debug(`response push to service for incident id: ${data.id}`);

View file

@ -16,7 +16,7 @@ export const SERVICENOW_ITSM = i18n.translate('xpack.actions.builtin.serviceNowI
});
export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSIRTitle', {
defaultMessage: 'ServiceNow SIR',
defaultMessage: 'ServiceNow SecOps',
});
export const ALLOWED_HOSTS_ERROR = (message: string) =>

View file

@ -121,6 +121,7 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr
params: PushToServiceApiParams;
secrets: Record<string, unknown>;
logger: Logger;
commentFieldKey: string;
}
export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {

View file

@ -80,8 +80,6 @@ export const CasePostRequestRt = rt.type({
settings: SettingsRt,
});
export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt;
export const CasesFindRequestRt = rt.partial({
tags: rt.union([rt.array(rt.string), rt.string]),
status: CaseStatusRt,
@ -126,6 +124,31 @@ export const CasePatchRequestRt = rt.intersection([
export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) });
export const CasesResponseRt = rt.array(CaseResponseRt);
export const CasePushRequestParamsRt = rt.type({
case_id: rt.string,
connector_id: rt.string,
});
export const ExternalServiceResponseRt = rt.intersection([
rt.type({
title: rt.string,
id: rt.string,
pushedDate: rt.string,
url: rt.string,
}),
rt.partial({
comments: rt.array(
rt.intersection([
rt.type({
commentId: rt.string,
pushedDate: rt.string,
}),
rt.partial({ externalCommentId: rt.string }),
])
),
}),
]);
export type CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>;
export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
export type CaseResponse = rt.TypeOf<typeof CaseResponseRt>;
@ -133,8 +156,8 @@ export type CasesResponse = rt.TypeOf<typeof CasesResponseRt>;
export type CasesFindResponse = rt.TypeOf<typeof CasesFindResponseRt>;
export type CasePatchRequest = rt.TypeOf<typeof CasePatchRequestRt>;
export type CasesPatchRequest = rt.TypeOf<typeof CasesPatchRequestRt>;
export type CaseExternalServiceRequest = rt.TypeOf<typeof CaseExternalServiceRequestRt>;
export type CaseFullExternalService = rt.TypeOf<typeof CaseFullExternalServiceRt>;
export type ExternalServiceResponse = rt.TypeOf<typeof ExternalServiceResponseRt>;
export type ESCaseAttributes = Omit<CaseAttributes, 'connector'> & { connector: ESCaseConnector };
export type ESCasePatchRequest = Omit<CasePatchRequest, 'connector'> & {

View file

@ -45,6 +45,14 @@ export const CommentResponseRt = rt.intersection([
}),
]);
export const CommentResponseTypeAlertsRt = rt.intersection([
AttributesTypeAlertsRt,
rt.type({
id: rt.string,
version: rt.string,
}),
]);
export const AllCommentsResponseRT = rt.array(CommentResponseRt);
export const CommentPatchRequestRt = rt.intersection([
@ -84,6 +92,7 @@ export const AllCommentsResponseRt = rt.array(CommentResponseRt);
export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>;
export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>;
export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>;
export type CommentResponseAlertsType = rt.TypeOf<typeof CommentResponseTypeAlertsRt>;
export type AllCommentsResponse = rt.TypeOf<typeof AllCommentsResponseRt>;
export type CommentsResponse = rt.TypeOf<typeof CommentsResponseRt>;
export type CommentPatchRequest = rt.TypeOf<typeof CommentPatchRequestRt>;

View file

@ -7,13 +7,9 @@
import * as rt from 'io-ts';
import { ActionResult, ActionType } from '../../../../actions/common';
import { UserRT } from '../user';
import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors';
export type ActionConnector = ActionResult;
export type ActionTypeConnector = ActionType;
// TODO: we will need to add this type rt.literal('close-by-third-party')
const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]);

View file

@ -7,25 +7,34 @@
import * as rt from 'io-ts';
import { ActionResult, ActionType } from '../../../../actions/common';
import { JiraFieldsRT } from './jira';
import { ResilientFieldsRT } from './resilient';
import { ServiceNowFieldsRT } from './servicenow';
import { ServiceNowITSMFieldsRT } from './servicenow_itsm';
import { ServiceNowSIRFieldsRT } from './servicenow_sir';
export * from './jira';
export * from './servicenow';
export * from './servicenow_itsm';
export * from './servicenow_sir';
export * from './resilient';
export * from './mappings';
export type ActionConnector = ActionResult;
export type ActionTypeConnector = ActionType;
export const ConnectorFieldsRt = rt.union([
JiraFieldsRT,
ResilientFieldsRT,
ServiceNowFieldsRT,
ServiceNowITSMFieldsRT,
ServiceNowSIRFieldsRT,
rt.null,
]);
export enum ConnectorTypes {
jira = '.jira',
resilient = '.resilient',
servicenow = '.servicenow',
serviceNowITSM = '.servicenow',
serviceNowSIR = '.servicenow-sir',
none = '.none',
}
@ -39,9 +48,14 @@ const ConnectorResillientTypeFieldsRt = rt.type({
fields: rt.union([ResilientFieldsRT, rt.null]),
});
const ConnectorServiceNowTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.servicenow),
fields: rt.union([ServiceNowFieldsRT, rt.null]),
const ConnectorServiceNowITSMTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.serviceNowITSM),
fields: rt.union([ServiceNowITSMFieldsRT, rt.null]),
});
const ConnectorServiceNowSIRTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.serviceNowSIR),
fields: rt.union([ServiceNowSIRFieldsRT, rt.null]),
});
const ConnectorNoneTypeFieldsRt = rt.type({
@ -52,7 +66,8 @@ const ConnectorNoneTypeFieldsRt = rt.type({
export const ConnectorTypeFieldsRt = rt.union([
ConnectorJiraTypeFieldsRt,
ConnectorResillientTypeFieldsRt,
ConnectorServiceNowTypeFieldsRt,
ConnectorServiceNowITSMTypeFieldsRt,
ConnectorServiceNowSIRTypeFieldsRt,
ConnectorNoneTypeFieldsRt,
]);
@ -66,6 +81,12 @@ export const CaseConnectorRt = rt.intersection([
export type CaseConnector = rt.TypeOf<typeof CaseConnectorRt>;
export type ConnectorTypeFields = rt.TypeOf<typeof ConnectorTypeFieldsRt>;
export type ConnectorJiraTypeFields = rt.TypeOf<typeof ConnectorJiraTypeFieldsRt>;
export type ConnectorResillientTypeFields = rt.TypeOf<typeof ConnectorResillientTypeFieldsRt>;
export type ConnectorServiceNowITSMTypeFields = rt.TypeOf<
typeof ConnectorServiceNowITSMTypeFieldsRt
>;
export type ConnectorServiceNowSIRTypeFields = rt.TypeOf<typeof ConnectorServiceNowSIRTypeFieldsRt>;
// we need to change these types back and forth for storing in ES (arrays overwrite, objects merge)
export type ConnectorFields = rt.TypeOf<typeof ConnectorFieldsRt>;

View file

@ -5,42 +5,7 @@
* 2.0.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import * as rt from 'io-ts';
import {
PushToServiceApiParams as JiraPushToServiceApiParams,
Incident as JiraIncident,
} from '../../../../actions/server/builtin_action_types/jira/types';
import {
PushToServiceApiParams as ResilientPushToServiceApiParams,
Incident as ResilientIncident,
} from '../../../../actions/server/builtin_action_types/resilient/types';
import {
PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams,
ServiceNowITSMIncident,
} from '../../../../actions/server/builtin_action_types/servicenow/types';
import { ResilientFieldsRT } from './resilient';
import { ServiceNowFieldsRT } from './servicenow';
import { JiraFieldsRT } from './jira';
// Formerly imported from security_solution
export interface ElasticUser {
readonly email?: string | null;
readonly fullName?: string | null;
readonly username?: string | null;
}
export {
JiraPushToServiceApiParams,
ResilientPushToServiceApiParams,
ServiceNowITSMPushToServiceApiParams,
};
export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident;
export type PushToServiceApiParams =
| JiraPushToServiceApiParams
| ResilientPushToServiceApiParams
| ServiceNowITSMPushToServiceApiParams;
const ActionTypeRT = rt.union([
rt.literal('append'),
@ -52,6 +17,7 @@ const CaseFieldRT = rt.union([
rt.literal('description'),
rt.literal('comments'),
]);
const ThirdPartyFieldRT = rt.union([rt.string, rt.literal('not_mapped')]);
export type ActionType = rt.TypeOf<typeof ActionTypeRT>;
export type CaseField = rt.TypeOf<typeof CaseFieldRT>;
@ -62,9 +28,11 @@ export const ConnectorMappingsAttributesRT = rt.type({
source: CaseFieldRT,
target: ThirdPartyFieldRT,
});
export const ConnectorMappingsRt = rt.type({
mappings: rt.array(ConnectorMappingsAttributesRT),
});
export type ConnectorMappingsAttributes = rt.TypeOf<typeof ConnectorMappingsAttributesRT>;
export type ConnectorMappings = rt.TypeOf<typeof ConnectorMappingsRt>;
@ -76,125 +44,12 @@ const ConnectorFieldRt = rt.type({
required: rt.boolean,
type: FieldTypeRT,
});
export type ConnectorField = rt.TypeOf<typeof ConnectorFieldRt>;
export const ConnectorRequestParamsRt = rt.type({
connector_id: rt.string,
});
export const GetFieldsRequestQueryRt = rt.type({
connector_type: rt.string,
});
const GetFieldsResponseRt = rt.type({
defaultMappings: rt.array(ConnectorMappingsAttributesRT),
fields: rt.array(ConnectorFieldRt),
});
export type GetFieldsResponse = rt.TypeOf<typeof GetFieldsResponseRt>;
export type ExternalServiceParams = Record<string, unknown>;
export interface PipedField {
actionType: string;
key: string;
pipes: string[];
value: string;
}
export interface PrepareFieldsForTransformArgs {
defaultPipes: string[];
mappings: ConnectorMappingsAttributes[];
params: ServiceConnectorCaseParams;
}
export interface EntityInformation {
createdAt: string;
createdBy: ElasticUser;
updatedAt: string | null;
updatedBy: ElasticUser | null;
}
export interface TransformerArgs {
date?: string;
previousValue?: string;
user?: string;
value: string;
}
export type Transformer = (args: TransformerArgs) => TransformerArgs;
export interface TransformFieldsArgs<P, S> {
currentIncident?: S;
fields: PipedField[];
params: P;
}
export const ServiceConnectorUserParams = rt.type({
fullName: rt.union([rt.string, rt.null]),
username: rt.string,
});
export const ServiceConnectorCommentParamsRt = rt.type({
commentId: rt.string,
comment: rt.string,
createdAt: rt.string,
createdBy: ServiceConnectorUserParams,
updatedAt: rt.union([rt.string, rt.null]),
updatedBy: rt.union([ServiceConnectorUserParams, rt.null]),
});
export const ServiceConnectorBasicCaseParamsRt = rt.type({
comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]),
createdAt: rt.string,
createdBy: ServiceConnectorUserParams,
description: rt.union([rt.string, rt.null]),
externalId: rt.union([rt.string, rt.null]),
savedObjectId: rt.string,
title: rt.string,
updatedAt: rt.union([rt.string, rt.null]),
updatedBy: rt.union([ServiceConnectorUserParams, rt.null]),
});
export const ConnectorPartialFieldsRt = rt.partial({
...JiraFieldsRT.props,
...ResilientFieldsRT.props,
...ServiceNowFieldsRT.props,
});
export const ServiceConnectorCaseParamsRt = rt.intersection([
ServiceConnectorBasicCaseParamsRt,
ConnectorPartialFieldsRt,
]);
export const ServiceConnectorCaseResponseRt = rt.intersection([
rt.type({
title: rt.string,
id: rt.string,
pushedDate: rt.string,
url: rt.string,
}),
rt.partial({
comments: rt.array(
rt.intersection([
rt.type({
commentId: rt.string,
pushedDate: rt.string,
}),
rt.partial({ externalCommentId: rt.string }),
])
),
}),
]);
export type ServiceConnectorBasicCaseParams = rt.TypeOf<typeof ServiceConnectorBasicCaseParamsRt>;
export type ServiceConnectorCaseParams = rt.TypeOf<typeof ServiceConnectorCaseParamsRt>;
export type ServiceConnectorCaseResponse = rt.TypeOf<typeof ServiceConnectorCaseResponseRt>;
export type ServiceConnectorCommentParams = rt.TypeOf<typeof ServiceConnectorCommentParamsRt>;
export const PostPushRequestRt = rt.type({
connector_type: rt.string,
params: ServiceConnectorCaseParamsRt,
});
export type PostPushRequest = rt.TypeOf<typeof PostPushRequestRt>;
export interface SimpleComment {
comment: string;
commentId: string;
}
export interface MapIncident {
incident: ExternalServiceParams;
comments: SimpleComment[];
}

View file

@ -7,10 +7,10 @@
import * as rt from 'io-ts';
export const ServiceNowFieldsRT = rt.type({
export const ServiceNowITSMFieldsRT = rt.type({
impact: rt.union([rt.string, rt.null]),
severity: rt.union([rt.string, rt.null]),
urgency: rt.union([rt.string, rt.null]),
});
export type ServiceNowFieldsType = rt.TypeOf<typeof ServiceNowFieldsRT>;
export type ServiceNowITSMFieldsType = rt.TypeOf<typeof ServiceNowITSMFieldsRT>;

View file

@ -0,0 +1,20 @@
/*
* 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 * as rt from 'io-ts';
export const ServiceNowSIRFieldsRT = rt.type({
category: rt.union([rt.string, rt.null]),
destIp: rt.union([rt.boolean, rt.null]),
malwareHash: rt.union([rt.boolean, rt.null]),
malwareUrl: rt.union([rt.boolean, rt.null]),
priority: rt.union([rt.string, rt.null]),
sourceIp: rt.union([rt.boolean, rt.null]),
subcategory: rt.union([rt.string, rt.null]),
});
export type ServiceNowSIRFieldsType = rt.TypeOf<typeof ServiceNowSIRFieldsRT>;

View file

@ -10,7 +10,7 @@ import {
CASE_COMMENTS_URL,
CASE_USER_ACTIONS_URL,
CASE_COMMENT_DETAILS_URL,
CASE_CONFIGURE_PUSH_URL,
CASE_PUSH_URL,
} from '../constants';
export const getCaseDetailsUrl = (id: string): string => {
@ -28,6 +28,6 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str
export const getCaseUserActionUrl = (id: string): string => {
return CASE_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getCaseConfigurePushUrl = (id: string): string => {
return CASE_CONFIGURE_PUSH_URL.replace('{connector_id}', id);
export const getCasePushUrl = (caseId: string, connectorId: string): string => {
return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId);
};

View file

@ -15,10 +15,9 @@ export const CASES_URL = '/api/cases';
export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`;
export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`;
export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`;
export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`;
export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`;
export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`;
export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`;
export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`;
export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`;
export const CASE_STATUS_URL = `${CASES_URL}/status`;
export const CASE_TAGS_URL = `${CASES_URL}/tags`;
@ -30,12 +29,14 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`;
export const ACTION_URL = '/api/actions';
export const ACTION_TYPES_URL = '/api/actions/list_action_types';
export const SERVICENOW_ACTION_TYPE_ID = '.servicenow';
export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
export const JIRA_ACTION_TYPE_ID = '.jira';
export const RESILIENT_ACTION_TYPE_ID = '.resilient';
export const SUPPORTED_CONNECTORS = [
SERVICENOW_ACTION_TYPE_ID,
SERVICENOW_ITSM_ACTION_TYPE_ID,
SERVICENOW_SIR_ACTION_TYPE_ID,
JIRA_ACTION_TYPE_ID,
RESILIENT_ACTION_TYPE_ID,
];

View file

@ -0,0 +1,31 @@
/*
* 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 Boom from '@hapi/boom';
import { CaseClientGetAlerts, CaseClientFactoryArguments } from '../types';
import { CaseClientGetAlertsResponse } from './types';
export const get = ({ alertsService, request, context }: CaseClientFactoryArguments) => async ({
ids,
}: CaseClientGetAlerts): Promise<CaseClientGetAlertsResponse> => {
const securitySolutionClient = context?.securitySolution?.getAppClient();
if (securitySolutionClient == null) {
throw Boom.notFound('securitySolutionClient client have not been found');
}
if (ids.length === 0) {
return [];
}
const index = securitySolutionClient.getSignalsIndex();
const alerts = await alertsService.getAlerts({ ids, index, request });
return alerts.hits.hits.map((alert) => ({
id: alert._id,
index: alert._index,
...alert._source,
}));
};

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
interface Alert {
id: string;
index: string;
destination?: {
ip: string;
};
source?: {
ip: string;
};
}
export type CaseClientGetAlertsResponse = Alert[];

View file

@ -0,0 +1,45 @@
/*
* 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 { flattenCaseSavedObject } from '../../routes/api/utils';
import { CaseResponseRt, CaseResponse } from '../../../common/api';
import { CaseClientGet, CaseClientFactoryArguments } from '../types';
export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArguments) => async ({
id,
includeComments = false,
}: CaseClientGet): Promise<CaseResponse> => {
const theCase = await caseService.getCase({
client: savedObjectsClient,
caseId: id,
});
if (!includeComments) {
return CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: theCase,
})
);
}
const theComments = await caseService.getAllCaseComments({
client: savedObjectsClient,
caseId: id,
options: {
sortField: 'created_at',
sortOrder: 'asc',
},
});
return CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: theCase,
comments: theComments.saved_objects,
totalComment: theComments.total,
})
);
};

View file

@ -0,0 +1,191 @@
/*
* 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 {
CommentResponse,
CommentType,
ConnectorMappingsAttributes,
CaseUserActionsResponse,
} from '../../../common/api';
import { BasicParams } from './types';
export const updateUser = {
updated_at: '2020-03-13T08:34:53.450Z',
updated_by: { full_name: 'Another User', username: 'another', email: 'elastic@elastic.co' },
};
const entity = {
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { full_name: 'Elastic User', username: 'elastic', email: 'elastic@elastic.co' },
updatedAt: null,
updatedBy: null,
};
export const comment: CommentResponse = {
id: 'mock-comment-1',
comment: 'Wow, good luck catching that bad meanie!',
type: CommentType.user as const,
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
pushed_at: null,
pushed_by: null,
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
version: 'WzEsMV0=',
};
export const commentAlert: CommentResponse = {
id: 'mock-comment-1',
alertId: 'alert-id-1',
index: 'alert-index-1',
type: CommentType.alert as const,
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
pushed_at: null,
pushed_by: null,
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
version: 'WzEsMV0=',
};
export const defaultPipes = ['informationCreated'];
export const basicParams: BasicParams = {
description: 'a description',
title: 'a title',
...entity,
};
export const mappings: ConnectorMappingsAttributes[] = [
{
source: 'title',
target: 'short_description',
action_type: 'overwrite',
},
{
source: 'description',
target: 'description',
action_type: 'append',
},
{
source: 'comments',
target: 'comments',
action_type: 'append',
},
];
export const userActions: CaseUserActionsResponse = [
{
action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'],
action: 'create',
action_at: '2021-02-03T17:41:03.771Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value:
'{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"id":"456","name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}',
old_value: null,
action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: null,
},
{
action_field: ['pushed'],
action: 'push-to-service',
action_at: '2021-02-03T17:41:26.108Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value:
'{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
old_value: null,
action_id: '0a801750-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: null,
},
{
action_field: ['comment'],
action: 'create',
action_at: '2021-02-03T17:44:21.067Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value: '{"type":"alert","alertId":"alert-id-1","index":".siem-signals-default-000008"}',
old_value: null,
action_id: '7373eb60-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: 'comment-alert-1',
},
{
action_field: ['comment'],
action: 'create',
action_at: '2021-02-03T17:44:33.078Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value: '{"type":"alert","alertId":"alert-id-2","index":".siem-signals-default-000008"}',
old_value: null,
action_id: '7abc6410-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: 'comment-alert-2',
},
{
action_field: ['pushed'],
action: 'push-to-service',
action_at: '2021-02-03T17:45:29.400Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value:
'{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
old_value: null,
action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: null,
},
{
action_field: ['comment'],
action: 'create',
action_at: '2021-02-03T17:48:30.616Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value: '{"comment":"a comment!","type":"user"}',
old_value: null,
action_id: '0818e5e0-6648-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: 'comment-user-1',
},
];

View file

@ -0,0 +1,266 @@
/*
* 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 Boom, { isBoom, Boom as BoomType } from '@hapi/boom';
import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse } from 'kibana/server';
import { flattenCaseSavedObject } from '../../routes/api/utils';
import {
ActionConnector,
CaseResponseRt,
CaseResponse,
CaseStatuses,
ExternalServiceResponse,
ESCaseAttributes,
CommentAttributes,
} from '../../../common/api';
import { buildCaseUserActionItem } from '../../services/user_actions/helpers';
import { CaseClientPush, CaseClientFactoryArguments } from '../types';
import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils';
const createError = (e: Error | BoomType, message: string): Error | BoomType => {
if (isBoom(e)) {
e.message = message;
e.output.payload.message = message;
return e;
}
return Error(message);
};
export const push = ({
savedObjectsClient,
caseService,
caseConfigureService,
userActionService,
request,
response,
}: CaseClientFactoryArguments) => async ({
actionsClient,
caseClient,
caseId,
connectorId,
}: CaseClientPush): Promise<CaseResponse> => {
/* Start of push to external service */
let theCase;
let connector;
let userActions;
let alerts;
let connectorMappings;
let externalServiceIncident;
try {
[theCase, connector, userActions] = await Promise.all([
caseClient.get({ id: caseId, includeComments: true }),
actionsClient.get({ id: connectorId }),
caseClient.getUserActions({ caseId }),
]);
} catch (e) {
const message = `Error getting case and/or connector and/or user actions: ${e.message}`;
throw createError(e, message);
}
// We need to change the logic when we support subcases
if (theCase?.status === CaseStatuses.closed) {
throw Boom.conflict(
`This case ${theCase.title} is closed. You can not pushed if the case is closed.`
);
}
try {
alerts = await caseClient.getAlerts({
ids: theCase?.comments?.filter(isCommentAlertType).map((comment) => comment.alertId) ?? [],
});
} catch (e) {
throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`);
}
try {
connectorMappings = await caseClient.getMappings({
actionsClient,
caseClient,
connectorId: connector.id,
connectorType: connector.actionTypeId,
});
} catch (e) {
const message = `Error getting mapping for connector with id ${connector.id}: ${e.message}`;
throw createError(e, message);
}
try {
externalServiceIncident = await createIncident({
actionsClient,
theCase,
userActions,
connector: connector as ActionConnector,
mappings: connectorMappings,
alerts,
});
} catch (e) {
const message = `Error creating incident for case with id ${theCase.id}: ${e.message}`;
throw createError(e, message);
}
const pushRes = await actionsClient.execute({
actionId: connector?.id ?? '',
params: {
subAction: 'pushToService',
subActionParams: externalServiceIncident,
},
});
if (pushRes.status === 'error') {
throw Boom.failedDependency(
pushRes.serviceMessage ?? pushRes.message ?? 'Error pushing to service'
);
}
/* End of push to external service */
/* Start of update case with push information */
let user;
let myCase;
let myCaseConfigure;
let comments;
try {
[user, myCase, myCaseConfigure, comments] = await Promise.all([
caseService.getUser({ request, response }),
caseService.getCase({
client: savedObjectsClient,
caseId,
}),
caseConfigureService.find({ client: savedObjectsClient }),
caseService.getAllCaseComments({
client: savedObjectsClient,
caseId,
options: {
fields: [],
page: 1,
perPage: theCase?.totalComment ?? 0,
},
}),
]);
} catch (e) {
const message = `Error getting user and/or case and/or case configuration and/or case comments: ${e.message}`;
throw createError(e, message);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const { username, full_name, email } = user;
const pushedDate = new Date().toISOString();
const externalServiceResponse = pushRes.data as ExternalServiceResponse;
const externalService = {
pushed_at: pushedDate,
pushed_by: { username, full_name, email },
connector_id: connector.id,
connector_name: connector.name,
external_id: externalServiceResponse.id,
external_title: externalServiceResponse.title,
external_url: externalServiceResponse.url,
};
let updatedCase: SavedObjectsUpdateResponse<ESCaseAttributes>;
let updatedComments: SavedObjectsBulkUpdateResponse<CommentAttributes>;
try {
[updatedCase, updatedComments] = await Promise.all([
caseService.patchCase({
client: savedObjectsClient,
caseId,
updatedAttributes: {
...(myCaseConfigure.total > 0 &&
myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing'
? {
status: CaseStatuses.closed,
closed_at: pushedDate,
closed_by: { email, full_name, username },
}
: {}),
external_service: externalService,
updated_at: pushedDate,
updated_by: { username, full_name, email },
},
version: myCase.version,
}),
caseService.patchComments({
client: savedObjectsClient,
comments: comments.saved_objects
.filter((comment) => comment.attributes.pushed_at == null)
.map((comment) => ({
commentId: comment.id,
updatedAttributes: {
pushed_at: pushedDate,
pushed_by: { username, full_name, email },
},
version: comment.version,
})),
}),
userActionService.postUserActions({
client: savedObjectsClient,
actions: [
...(myCaseConfigure.total > 0 &&
myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing'
? [
buildCaseUserActionItem({
action: 'update',
actionAt: pushedDate,
actionBy: { username, full_name, email },
caseId,
fields: ['status'],
newValue: CaseStatuses.closed,
oldValue: myCase.attributes.status,
}),
]
: []),
buildCaseUserActionItem({
action: 'push-to-service',
actionAt: pushedDate,
actionBy: { username, full_name, email },
caseId,
fields: ['pushed'],
newValue: JSON.stringify(externalService),
}),
],
}),
]);
} catch (e) {
const message = `Error updating case and/or comments and/or creating user action: ${e.message}`;
throw createError(e, message);
}
/* End of update case with push information */
return CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: {
...myCase,
...updatedCase,
attributes: { ...myCase.attributes, ...updatedCase?.attributes },
references: myCase.references,
},
comments: comments.saved_objects.map((origComment) => {
const updatedComment = updatedComments.saved_objects.find((c) => c.id === origComment.id);
return {
...origComment,
...updatedComment,
attributes: {
...origComment.attributes,
...updatedComment?.attributes,
...getCommentContextFromAttributes(origComment.attributes),
},
version: updatedComment?.version ?? origComment.version,
references: origComment?.references ?? [],
};
}),
})
);
};

View file

@ -0,0 +1,81 @@
/*
* 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.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import {
PushToServiceApiParams as JiraPushToServiceApiParams,
Incident as JiraIncident,
} from '../../../../actions/server/builtin_action_types/jira/types';
import {
PushToServiceApiParams as ResilientPushToServiceApiParams,
Incident as ResilientIncident,
} from '../../../../actions/server/builtin_action_types/resilient/types';
import {
PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams,
PushToServiceApiParamsSIR as ServiceNowSIRPushToServiceApiParams,
ServiceNowITSMIncident,
} from '../../../../actions/server/builtin_action_types/servicenow/types';
import { CaseResponse, ConnectorMappingsAttributes } from '../../../common/api';
export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident;
export type PushToServiceApiParams =
| JiraPushToServiceApiParams
| ResilientPushToServiceApiParams
| ServiceNowITSMPushToServiceApiParams
| ServiceNowSIRPushToServiceApiParams;
export type ExternalServiceParams = Record<string, unknown>;
export interface BasicParams {
title: CaseResponse['title'];
description: CaseResponse['description'];
createdAt: CaseResponse['created_at'];
createdBy: CaseResponse['created_by'];
updatedAt: CaseResponse['updated_at'];
updatedBy: CaseResponse['updated_by'];
}
export interface PipedField {
actionType: string;
key: string;
pipes: string[];
value: string;
}
export interface PrepareFieldsForTransformArgs {
defaultPipes: string[];
mappings: ConnectorMappingsAttributes[];
params: { title: string; description: string };
}
export interface EntityInformation {
createdAt: CaseResponse['created_at'];
createdBy: CaseResponse['created_by'];
updatedAt: CaseResponse['updated_at'];
updatedBy: CaseResponse['updated_by'];
}
export interface TransformerArgs {
date?: string;
previousValue?: string;
user?: string;
value: string;
}
export type Transformer = (args: TransformerArgs) => TransformerArgs;
export interface TransformFieldsArgs<P, S> {
currentIncident?: S;
fields: PipedField[];
params: P;
}
export interface ExternalServiceComment {
comment: string;
commentId: string;
}
export interface MapIncident {
incident: ExternalServiceParams;
comments: ExternalServiceComment[];
}

View file

@ -5,34 +5,45 @@
* 2.0.
*/
import { actionsClientMock } from '../../../../actions/server/actions_client.mock';
import { flattenCaseSavedObject } from '../../routes/api/utils';
import { mockCases } from '../../routes/api/__fixtures__';
import { BasicParams, ExternalServiceParams, Incident } from './types';
import {
mapIncident,
comment as commentObj,
mappings,
defaultPipes,
basicParams,
userActions,
commentAlert,
} from './mock';
import {
createIncident,
getLatestPushInfo,
prepareFieldsForTransformation,
serviceFormatter,
transformComments,
transformers,
transformFields,
} from './utils';
import { comment as commentObj, mappings, defaultPipes, params, updateUser } from './mock';
import {
ConnectorTypes,
ExternalServiceParams,
Incident,
ServiceConnectorCaseParams,
} from '../../../../../common/api/connectors';
import { actionsClientMock } from '../../../../../../actions/server/actions_client.mock';
import { mappings as mappingsMock } from '../../../../client/configure/mock';
const formatComment = { commentId: commentObj.commentId, comment: commentObj.comment };
const serviceNowParams = params[ConnectorTypes.servicenow] as ServiceConnectorCaseParams;
describe('api/cases/configure/utils', () => {
const formatComment = {
commentId: commentObj.id,
comment: 'Wow, good luck catching that bad meanie!',
};
const params = { ...basicParams };
describe('utils', () => {
describe('prepareFieldsForTransformation', () => {
test('prepare fields with defaults', () => {
const res = prepareFieldsForTransformation({
defaultPipes,
params: serviceNowParams,
params,
mappings,
});
expect(res).toEqual([
{
actionType: 'overwrite',
@ -53,8 +64,9 @@ describe('api/cases/configure/utils', () => {
const res = prepareFieldsForTransformation({
defaultPipes: ['myTestPipe'],
mappings,
params: serviceNowParams,
params,
});
expect(res).toEqual([
{
actionType: 'overwrite',
@ -71,16 +83,17 @@ describe('api/cases/configure/utils', () => {
]);
});
});
describe('transformFields', () => {
test('transform fields for creation correctly', () => {
const fields = prepareFieldsForTransformation({
defaultPipes,
mappings,
params: serviceNowParams,
params,
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params: serviceNowParams,
const res = transformFields<BasicParams, ExternalServiceParams, Incident>({
params,
fields,
});
@ -92,18 +105,19 @@ describe('api/cases/configure/utils', () => {
test('transform fields for update correctly', () => {
const fields = prepareFieldsForTransformation({
params: serviceNowParams,
params,
mappings,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
const res = transformFields<BasicParams, ExternalServiceParams, Incident>({
params: {
...serviceNowParams,
...params,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
username: 'anotherUser',
fullName: 'Another User',
full_name: 'Another User',
email: 'elastic@elastic.co',
},
},
fields,
@ -112,6 +126,7 @@ describe('api/cases/configure/utils', () => {
description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
expect(res).toEqual({
short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)',
description:
@ -121,13 +136,13 @@ describe('api/cases/configure/utils', () => {
test('add newline character to description', () => {
const fields = prepareFieldsForTransformation({
params: serviceNowParams,
params,
mappings,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params: serviceNowParams,
const res = transformFields<BasicParams, ExternalServiceParams, Incident>({
params,
fields,
currentIncident: {
short_description: 'first title',
@ -141,13 +156,13 @@ describe('api/cases/configure/utils', () => {
const fields = prepareFieldsForTransformation({
defaultPipes,
mappings,
params: serviceNowParams,
params,
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
const res = transformFields<BasicParams, ExternalServiceParams, Incident>({
params: {
...serviceNowParams,
createdBy: { fullName: '', username: 'elastic' },
...params,
createdBy: { full_name: '', username: 'elastic', email: 'elastic@elastic.co' },
},
fields,
});
@ -162,14 +177,14 @@ describe('api/cases/configure/utils', () => {
const fields = prepareFieldsForTransformation({
defaultPipes: ['informationUpdated'],
mappings,
params: serviceNowParams,
params,
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
const res = transformFields<BasicParams, ExternalServiceParams, Incident>({
params: {
...serviceNowParams,
...params,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: { username: 'anotherUser', fullName: '' },
updatedBy: { username: 'anotherUser', full_name: '', email: 'elastic@elastic.co' },
},
fields,
});
@ -180,6 +195,7 @@ describe('api/cases/configure/utils', () => {
});
});
});
describe('transformComments', () => {
test('transform creation comments', () => {
const comments = [commentObj];
@ -187,7 +203,7 @@ describe('api/cases/configure/utils', () => {
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (created at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`,
comment: `${formatComment.comment} (created at ${comments[0].created_at} by ${comments[0].created_by.full_name})`,
},
]);
});
@ -196,14 +212,19 @@ describe('api/cases/configure/utils', () => {
const comments = [
{
...commentObj,
...updateUser,
updated_at: '2020-03-13T08:34:53.450Z',
updated_by: {
full_name: 'Another User',
username: 'another',
email: 'elastic@elastic.co',
},
},
];
const res = transformComments(comments, ['informationUpdated']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (updated at ${updateUser.updatedAt} by ${updateUser.updatedBy.fullName})`,
comment: `${formatComment.comment} (updated at ${comments[0].updated_at} by ${comments[0].updated_by.full_name})`,
},
]);
});
@ -214,19 +235,19 @@ describe('api/cases/configure/utils', () => {
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`,
comment: `${formatComment.comment} (added at ${comments[0].created_at} by ${comments[0].created_by.full_name})`,
},
]);
});
test('transform comments without fullname', () => {
const comments = [{ ...commentObj, createdBy: { username: commentObj.createdBy.username } }];
// @ts-ignore testing no fullName
const comments = [{ ...commentObj, createdBy: { username: commentObj.created_by.username } }];
// @ts-ignore testing no full_name
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.username})`,
comment: `${formatComment.comment} (added at ${comments[0].created_at} by ${comments[0].created_by.username})`,
},
]);
});
@ -235,15 +256,15 @@ describe('api/cases/configure/utils', () => {
const comments = [
{
...commentObj,
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic2', username: 'elastic' },
updated_at: '2020-04-13T08:34:53.450Z',
updated_by: { full_name: 'Elastic2', username: 'elastic', email: 'elastic@elastic.co' },
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.fullName})`,
comment: `${formatComment.comment} (added at ${comments[0].updated_at} by ${comments[0].updated_by.full_name})`,
},
]);
});
@ -252,19 +273,20 @@ describe('api/cases/configure/utils', () => {
const comments = [
{
...commentObj,
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: '', username: 'elastic2' },
updated_at: '2020-04-13T08:34:53.450Z',
updated_by: { full_name: '', username: 'elastic2', email: 'elastic@elastic.co' },
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.username})`,
comment: `${formatComment.comment} (added at ${comments[0].updated_at} by ${comments[0].updated_by.username})`,
},
]);
});
});
describe('transformers', () => {
const { informationCreated, informationUpdated, informationAdded, append } = transformers;
describe('informationCreated', () => {
@ -389,142 +411,291 @@ describe('api/cases/configure/utils', () => {
});
});
});
describe('mapIncident', () => {
describe('createIncident', () => {
let actionsMock = actionsClientMock.create();
it('maps an external incident', async () => {
const res = await mapIncident(
actionsMock,
'123',
ConnectorTypes.servicenow,
mappingsMock[ConnectorTypes.servicenow],
serviceNowParams
);
const theCase = {
...flattenCaseSavedObject({
savedObject: mockCases[0],
}),
comments: [commentObj],
totalComments: 1,
};
const connector = {
id: '456',
actionTypeId: '.jira',
name: 'Connector without isCaseOwned',
config: {
apiUrl: 'https://elastic.jira.com',
},
isPreconfigured: false,
};
it('creates an external incident', async () => {
const res = await createIncident({
actionsClient: actionsMock,
theCase,
userActions: [],
connector,
mappings,
alerts: [],
});
expect(res).toEqual({
incident: {
description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
priority: null,
labels: ['defacement'],
issueType: null,
parent: null,
short_description:
'Super Bad Security Issue (created at 2019-11-25T21:54:48.952Z by elastic)',
description:
'This is a brand new case of a bad meanie defacing data (created at 2019-11-25T21:54:48.952Z by elastic)',
externalId: null,
impact: '3',
severity: '1',
short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
urgency: '2',
},
comments: [
comments: [],
});
});
it('it creates comments correctly', async () => {
const res = await createIncident({
actionsClient: actionsMock,
theCase: {
...theCase,
comments: [{ ...commentObj, id: 'comment-user-1' }],
},
userActions,
connector,
mappings,
alerts: [],
});
expect(res.comments).toEqual([
{
comment:
'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)',
commentId: 'comment-user-1',
},
]);
});
it('it does NOT creates comments when mapping is nothing', async () => {
const res = await createIncident({
actionsClient: actionsMock,
theCase: {
...theCase,
comments: [{ ...commentObj, id: 'comment-user-1' }],
},
userActions,
connector,
mappings: [
mappings[0],
mappings[1],
{
comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
source: 'comments',
target: 'comments',
action_type: 'nothing',
},
],
alerts: [],
});
expect(res.comments).toEqual([]);
});
it('throws error if invalid service', async () => {
await mapIncident(
actionsMock,
'123',
'invalid',
mappingsMock[ConnectorTypes.servicenow],
serviceNowParams
).catch((e) => {
expect(e).not.toBeNull();
expect(e).toEqual(new Error(`Invalid service`));
it('it creates comments of type alert correctly', async () => {
const res = await createIncident({
actionsClient: actionsMock,
theCase: {
...theCase,
comments: [
{ ...commentObj, id: 'comment-user-1' },
{ ...commentAlert, id: 'comment-alert-1' },
{ ...commentAlert, id: 'comment-alert-2' },
],
},
// Remove second push
userActions: userActions.filter((item, index) => index !== 4),
connector,
mappings: [
...mappings,
{
source: 'comments',
target: 'comments',
action_type: 'nothing',
},
],
alerts: [],
});
expect(res.comments).toEqual([
{
comment:
'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)',
commentId: 'comment-user-1',
},
{
comment:
'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)',
commentId: 'comment-alert-1',
},
{
comment:
'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)',
commentId: 'comment-alert-2',
},
]);
});
it('updates an existing incident', async () => {
const existingIncidentData = {
description: 'fun description',
impact: '3',
severity: '3',
priority: null,
issueType: null,
parent: null,
short_description: 'fun title',
urgency: '3',
description: 'fun description',
};
const execute = jest.fn().mockReturnValue(existingIncidentData);
actionsMock = { ...actionsMock, execute };
const res = await mapIncident(
actionsMock,
'123',
ConnectorTypes.servicenow,
mappingsMock[ConnectorTypes.servicenow],
{ ...serviceNowParams, externalId: '123' }
);
const res = await createIncident({
actionsClient: actionsMock,
theCase,
userActions,
connector,
mappings,
alerts: [],
});
expect(res).toEqual({
incident: {
description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
externalId: '123',
impact: '3',
severity: '1',
short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
urgency: '2',
priority: null,
labels: ['defacement'],
issueType: null,
parent: null,
description:
'fun description \r\nThis is a brand new case of a bad meanie defacing data (updated at 2019-11-25T21:54:48.952Z by elastic)',
externalId: 'external-id',
short_description:
'Super Bad Security Issue (updated at 2019-11-25T21:54:48.952Z by elastic)',
},
comments: [
{
comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
},
],
comments: [],
});
});
it('throws error when existing incident throws', async () => {
expect.assertions(2);
const execute = jest.fn().mockImplementation(() => {
throw new Error('exception');
});
actionsMock = { ...actionsMock, execute };
await mapIncident(
actionsMock,
'123',
ConnectorTypes.servicenow,
mappingsMock[ConnectorTypes.servicenow],
{ ...serviceNowParams, externalId: '123' }
).catch((e) => {
createIncident({
actionsClient: actionsMock,
theCase,
userActions,
connector,
mappings,
alerts: [],
}).catch((e) => {
expect(e).not.toBeNull();
expect(e).toEqual(
new Error(
`Retrieving Incident by id 123 from ServiceNow failed with exception: Error: exception`
`Retrieving Incident by id external-id from .jira failed with exception: Error: exception`
)
);
});
});
});
const connectors = [
{
name: ConnectorTypes.jira,
result: {
incident: {
issueType: '10003',
parent: '5002',
priority: 'Highest',
},
thirdPartyName: 'Jira',
},
},
{
name: ConnectorTypes.resilient,
result: {
incident: {
incidentTypes: ['10003'],
severityCode: '1',
},
thirdPartyName: 'Resilient',
},
},
{
name: ConnectorTypes.servicenow,
result: {
incident: {
impact: '3',
severity: '1',
urgency: '2',
},
thirdPartyName: 'ServiceNow',
},
},
];
describe('serviceFormatter', () => {
connectors.forEach((c) =>
it(`formats ${c.name}`, () => {
const caseParams = params[c.name] as ServiceConnectorCaseParams;
const res = serviceFormatter(c.name, caseParams);
expect(res).toEqual(c.result);
})
);
it('throws error if connector is not supported', async () => {
expect.assertions(2);
createIncident({
actionsClient: actionsMock,
theCase,
userActions,
connector: { ...connector, actionTypeId: 'not-supported' },
mappings,
alerts: [],
}).catch((e) => {
expect(e).not.toBeNull();
expect(e).toEqual(new Error('Invalid external service'));
});
});
describe('getLatestPushInfo', () => {
it('it returns the latest push information correctly', async () => {
const res = getLatestPushInfo('456', userActions);
expect(res).toEqual({
index: 4,
pushedInfo: {
connector_id: '456',
connector_name: 'ServiceNow SN',
external_id: 'external-id',
external_title: 'SIR0010037',
external_url:
'https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id',
pushed_at: '2021-02-03T17:45:29.400Z',
pushed_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
},
});
});
it('it returns null when there are not actions', async () => {
const res = getLatestPushInfo('456', []);
expect(res).toBe(null);
});
it('it returns null when there are no push user action', async () => {
const res = getLatestPushInfo('456', [userActions[0]]);
expect(res).toBe(null);
});
it('it returns the correct push information when with multiple push on different connectors', async () => {
const res = getLatestPushInfo('456', [
...userActions.slice(0, 3),
{
action_field: ['pushed'],
action: 'push-to-service',
action_at: '2021-02-03T17:45:29.400Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value:
// The connector id is 123
'{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"123","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
old_value: null,
action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: null,
},
]);
expect(res).toEqual({
index: 1,
pushedInfo: {
connector_id: '456',
connector_name: 'ServiceNow SN',
external_id: 'external-id',
external_title: 'SIR0010037',
external_url:
'https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id',
pushed_at: '2021-02-03T17:41:26.108Z',
pushed_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
},
});
});
});
});
});

View file

@ -8,46 +8,118 @@
import { i18n } from '@kbn/i18n';
import { flow } from 'lodash';
import {
ServiceConnectorCaseParams,
ServiceConnectorCommentParams,
ActionConnector,
CaseResponse,
CaseFullExternalService,
CaseUserActionsResponse,
CommentResponse,
CommentResponseAlertsType,
CommentType,
ConnectorMappingsAttributes,
ConnectorTypes,
CommentAttributes,
CommentRequestUserType,
CommentRequestAlertType,
} from '../../../common/api';
import { ActionsClient } from '../../../../actions/server';
import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors';
import { CaseClientGetAlertsResponse } from '../../client/alerts/types';
import {
BasicParams,
EntityInformation,
ExternalServiceParams,
ExternalServiceComment,
Incident,
JiraPushToServiceApiParams,
MapIncident,
PipedField,
PrepareFieldsForTransformArgs,
PushToServiceApiParams,
ResilientPushToServiceApiParams,
ServiceNowITSMPushToServiceApiParams,
SimpleComment,
Transformer,
TransformerArgs,
TransformFieldsArgs,
} from '../../../../../common/api';
import { ActionsClient } from '../../../../../../actions/server';
export const mapIncident = async (
actionsClient: ActionsClient,
} from './types';
export const getLatestPushInfo = (
connectorId: string,
connectorType: string,
mappings: ConnectorMappingsAttributes[],
params: ServiceConnectorCaseParams
): Promise<MapIncident> => {
const { comments: caseComments, externalId } = params;
userActions: CaseUserActionsResponse
): { index: number; pushedInfo: CaseFullExternalService } | null => {
for (const [index, action] of [...userActions].reverse().entries()) {
if (action.action === 'push-to-service' && action.new_value)
try {
const pushedInfo = JSON.parse(action.new_value);
if (pushedInfo.connector_id === connectorId) {
// We returned the index of the element in the userActions array.
// As we traverse the userActions in reverse we need to calculate the index of a normal traversal
return { index: userActions.length - index - 1, pushedInfo };
}
} catch (e) {
// Silence JSON parse errors
}
}
return null;
};
const isConnectorSupported = (connectorId: string): connectorId is FormatterConnectorTypes =>
Object.values(ConnectorTypes).includes(connectorId as ConnectorTypes);
const getCommentContent = (comment: CommentResponse): string => {
if (comment.type === CommentType.user) {
return comment.comment;
} else if (comment.type === CommentType.alert) {
return `Alert with id ${comment.alertId} added to case`;
}
return '';
};
interface CreateIncidentArgs {
actionsClient: ActionsClient;
theCase: CaseResponse;
userActions: CaseUserActionsResponse;
connector: ActionConnector;
mappings: ConnectorMappingsAttributes[];
alerts: CaseClientGetAlertsResponse;
}
export const createIncident = async ({
actionsClient,
theCase,
userActions,
connector,
mappings,
alerts,
}: CreateIncidentArgs): Promise<MapIncident> => {
const {
comments: caseComments,
title,
description,
created_at: createdAt,
created_by: createdBy,
updated_at: updatedAt,
updated_by: updatedBy,
} = theCase;
if (!isConnectorSupported(connector.actionTypeId)) {
throw new Error('Invalid external service');
}
const params = { title, description, createdAt, createdBy, updatedAt, updatedBy };
const latestPushInfo = getLatestPushInfo(connector.id, userActions);
const externalId = latestPushInfo?.pushedInfo?.external_id ?? null;
const defaultPipes = externalId ? ['informationUpdated'] : ['informationCreated'];
let currentIncident: ExternalServiceParams | undefined;
const service = serviceFormatter(connectorType, params);
if (service == null) {
throw new Error(`Invalid service`);
}
const thirdPartyName = service.thirdPartyName;
let incident: Partial<PushToServiceApiParams['incident']> = service.incident;
const externalServiceFields = externalServiceFormatters[connector.actionTypeId].format(
theCase,
alerts
);
let incident: Partial<PushToServiceApiParams['incident']> = { ...externalServiceFields };
if (externalId) {
try {
currentIncident = ((await actionsClient.execute({
actionId: connectorId,
actionId: connector.id,
params: {
subAction: 'getIncident',
subActionParams: { externalId },
@ -55,80 +127,56 @@ export const mapIncident = async (
})) as unknown) as ExternalServiceParams | undefined;
} catch (ex) {
throw new Error(
`Retrieving Incident by id ${externalId} from ${thirdPartyName} failed with exception: ${ex}`
`Retrieving Incident by id ${externalId} from ${connector.actionTypeId} failed with exception: ${ex}`
);
}
}
const fields = prepareFieldsForTransformation({
defaultPipes,
mappings,
params,
});
const transformedFields = transformFields<
ServiceConnectorCaseParams,
ExternalServiceParams,
Incident
>({
const transformedFields = transformFields<BasicParams, ExternalServiceParams, Incident>({
params,
fields,
currentIncident,
});
incident = { ...incident, ...transformedFields, externalId };
let comments: SimpleComment[] = [];
if (caseComments && Array.isArray(caseComments) && caseComments.length > 0) {
const commentsIdsToBeUpdated = new Set(
userActions
.slice(latestPushInfo?.index ?? 0)
.filter(
(action, index) =>
Array.isArray(action.action_field) && action.action_field[0] === 'comment'
)
.map((action) => action.comment_id)
);
const commentsToBeUpdated = caseComments?.filter((comment) =>
commentsIdsToBeUpdated.has(comment.id)
);
let comments: ExternalServiceComment[] = [];
if (commentsToBeUpdated && Array.isArray(commentsToBeUpdated) && commentsToBeUpdated.length > 0) {
const commentsMapping = mappings.find((m) => m.source === 'comments');
if (commentsMapping?.action_type !== 'nothing') {
comments = transformComments(caseComments, ['informationAdded']);
comments = transformComments(commentsToBeUpdated, ['informationAdded']);
}
}
return { incident, comments };
};
export const serviceFormatter = (
connectorType: string,
params: unknown
): { thirdPartyName: string; incident: Partial<PushToServiceApiParams['incident']> } | null => {
switch (connectorType) {
case ConnectorTypes.jira:
const {
priority,
labels,
issueType,
parent,
} = params as JiraPushToServiceApiParams['incident'];
return {
incident: { priority, labels, issueType, parent },
thirdPartyName: 'Jira',
};
case ConnectorTypes.resilient:
const { incidentTypes, severityCode } = params as ResilientPushToServiceApiParams['incident'];
return {
incident: { incidentTypes, severityCode },
thirdPartyName: 'Resilient',
};
case ConnectorTypes.servicenow:
const {
severity,
urgency,
impact,
} = params as ServiceNowITSMPushToServiceApiParams['incident'];
return {
incident: { severity, urgency, impact },
thirdPartyName: 'ServiceNow',
};
default:
return null;
}
};
export const getEntity = (entity: EntityInformation): string =>
(entity.updatedBy != null
? entity.updatedBy.fullName
? entity.updatedBy.fullName
? entity.updatedBy.full_name
? entity.updatedBy.full_name
: entity.updatedBy.username
: entity.createdBy != null
? entity.createdBy.fullName
? entity.createdBy.fullName
? entity.createdBy.full_name
? entity.createdBy.full_name
: entity.createdBy.username
: '') ?? '';
@ -160,6 +208,7 @@ export const FIELD_INFORMATION = (
});
}
};
export const transformers: Record<string, Transformer> = {
informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${FIELD_INFORMATION('create', date, user)}`,
@ -178,6 +227,7 @@ export const transformers: Record<string, Transformer> = {
...rest,
}),
};
export const prepareFieldsForTransformation = ({
defaultPipes,
mappings,
@ -226,14 +276,46 @@ export const transformFields = <
};
export const transformComments = (
comments: ServiceConnectorCommentParams[],
comments: CaseResponse['comments'] = [],
pipes: string[]
): SimpleComment[] =>
): ExternalServiceComment[] =>
comments.map((c) => ({
comment: flow(...pipes.map((p) => transformers[p]))({
value: c.comment,
date: c.updatedAt ?? c.createdAt,
user: getEntity(c),
value: getCommentContent(c),
date: c.updated_at ?? c.created_at,
user: getEntity({
createdAt: c.created_at,
createdBy: c.created_by,
updatedAt: c.updated_at,
updatedBy: c.updated_by,
}),
}).value,
commentId: c.commentId,
commentId: c.id,
}));
export const isCommentAlertType = (
comment: CommentResponse
): comment is CommentResponseAlertsType => comment.type === CommentType.alert;
export const getCommentContextFromAttributes = (
attributes: CommentAttributes
): CommentRequestUserType | CommentRequestAlertType => {
switch (attributes.type) {
case CommentType.user:
return {
type: CommentType.user,
comment: attributes.comment,
};
case CommentType.alert:
return {
type: CommentType.alert,
alertId: attributes.alertId,
index: attributes.index,
};
default:
return {
type: CommentType.user,
comment: '',
};
}
};

View file

@ -70,7 +70,7 @@ export const mappings: TestMappings = {
action_type: 'append',
},
],
[ConnectorTypes.servicenow]: [
[ConnectorTypes.serviceNowITSM]: [
{
source: 'title',
target: 'short_description',
@ -611,7 +611,7 @@ export const formatFieldsTestData: FormatFieldsTestData[] = [
{ id: 'upon_reject', name: 'Upon reject', required: false, type: 'text' },
],
fields: serviceNowFields,
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
},
];
export const mockGetFieldsResponse = {

View file

@ -70,7 +70,9 @@ export const formatFields = (theData: unknown, theType: string): ConnectorField[
return normalizeJiraFields(theData as JiraGetFieldsResponse);
case ConnectorTypes.resilient:
return normalizeResilientFields(theData as ResilientGetFieldsResponse);
case ConnectorTypes.servicenow:
case ConnectorTypes.serviceNowITSM:
return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse);
case ConnectorTypes.serviceNowSIR:
return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse);
default:
return [];
@ -97,10 +99,14 @@ const getPreferredFields = (theType: string) => {
} else if (theType === ConnectorTypes.resilient) {
title = 'name';
description = 'description';
} else if (theType === ConnectorTypes.servicenow) {
} else if (
theType === ConnectorTypes.serviceNowITSM ||
theType === ConnectorTypes.serviceNowSIR
) {
title = 'short_description';
description = 'description';
}
return { title, description };
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { KibanaRequest } from 'kibana/server';
import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { createCaseClient } from '.';
import {
@ -17,29 +17,48 @@ import {
} from '../services/mocks';
import { create } from './cases/create';
import { get } from './cases/get';
import { update } from './cases/update';
import { push } from './cases/push';
import { addComment } from './comments/add';
import { getFields } from './configure/get_fields';
import { getMappings } from './configure/get_mappings';
import { updateAlertsStatus } from './alerts/update_status';
import { get as getUserActions } from './user_actions/get';
import { get as getAlerts } from './alerts/get';
import type { CasesRequestHandlerContext } from '../types';
jest.mock('./cases/create');
jest.mock('./cases/update');
jest.mock('./cases/get');
jest.mock('./cases/push');
jest.mock('./comments/add');
jest.mock('./alerts/update_status');
jest.mock('./alerts/get');
jest.mock('./user_actions/get');
jest.mock('./configure/get_fields');
jest.mock('./configure/get_mappings');
const caseConfigureService = createConfigureServiceMock();
const alertsService = createAlertServiceMock();
const caseService = createCaseServiceMock();
const connectorMappingsService = connectorMappingsServiceMock();
const request = {} as KibanaRequest;
const response = kibanaResponseFactory;
const savedObjectsClient = savedObjectsClientMock.create();
const userActionService = createUserActionServiceMock();
const context = {} as CasesRequestHandlerContext;
const createMock = create as jest.Mock;
const getMock = get as jest.Mock;
const updateMock = update as jest.Mock;
const pushMock = push as jest.Mock;
const addCommentMock = addComment as jest.Mock;
const updateAlertsStatusMock = updateAlertsStatus as jest.Mock;
const getAlertsStatusMock = getAlerts as jest.Mock;
const getFieldsMock = getFields as jest.Mock;
const getMappingsMock = getMappings as jest.Mock;
const getUserActionsMock = getUserActions as jest.Mock;
describe('createCaseClient()', () => {
test('it creates the client correctly', async () => {
@ -50,49 +69,34 @@ describe('createCaseClient()', () => {
connectorMappingsService,
context,
request,
response,
savedObjectsClient,
userActionService,
});
expect(createMock).toHaveBeenCalledWith({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
});
expect(updateMock).toHaveBeenCalledWith({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
});
expect(addCommentMock).toHaveBeenCalledWith({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
});
expect(updateAlertsStatusMock).toHaveBeenCalledWith({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
});
[
createMock,
getMock,
updateMock,
pushMock,
addCommentMock,
updateAlertsStatusMock,
getAlertsStatusMock,
getFieldsMock,
getMappingsMock,
getUserActionsMock,
].forEach((method) =>
expect(method).toHaveBeenCalledWith({
caseConfigureService,
caseService,
connectorMappingsService,
request,
response,
savedObjectsClient,
userActionService,
alertsService,
context,
})
);
});
});

View file

@ -5,73 +5,41 @@
* 2.0.
*/
import { CaseClientFactoryArguments, CaseClient } from './types';
import {
CaseClientFactoryArguments,
CaseClient,
CaseClientFactoryMethods,
CaseClientMethods,
} from './types';
import { create } from './cases/create';
import { get } from './cases/get';
import { update } from './cases/update';
import { push } from './cases/push';
import { addComment } from './comments/add';
import { getFields } from './configure/get_fields';
import { getMappings } from './configure/get_mappings';
import { updateAlertsStatus } from './alerts/update_status';
import { get as getUserActions } from './user_actions/get';
import { get as getAlerts } from './alerts/get';
export { CaseClient } from './types';
export const createCaseClient = ({
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
alertsService,
context,
}: CaseClientFactoryArguments): CaseClient => {
return {
create: create({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
}),
update: update({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
}),
addComment: addComment({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
}),
getFields: getFields(),
getMappings: getMappings({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
}),
updateAlertsStatus: updateAlertsStatus({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
}),
export const createCaseClient = (args: CaseClientFactoryArguments): CaseClient => {
const methods: CaseClientFactoryMethods = {
create,
get,
update,
push,
addComment,
getAlerts,
getFields,
getMappings,
getUserActions,
updateAlertsStatus,
};
return (Object.keys(methods) as CaseClientMethods[]).reduce((client, method) => {
client[method] = methods[method](args);
return client;
}, {} as CaseClient);
};

View file

@ -6,9 +6,9 @@
*/
import { omit } from 'lodash/fp';
import { KibanaRequest } from 'kibana/server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server/http';
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { actionsClientMock } from '../../../actions/server/mocks';
import {
AlertServiceContract,
CaseConfigureService,
@ -17,17 +17,20 @@ import {
ConnectorMappingsService,
} from '../services';
import { CaseClient } from './types';
import { authenticationMock } from '../routes/api/__fixtures__';
import { authenticationMock, createActionsClient } from '../routes/api/__fixtures__';
import { createCaseClient } from '.';
import { getActions } from '../routes/api/__mocks__/request_responses';
import type { CasesRequestHandlerContext } from '../types';
export type CaseClientMock = jest.Mocked<CaseClient>;
export const createCaseClientMock = (): CaseClientMock => ({
addComment: jest.fn(),
create: jest.fn(),
get: jest.fn(),
push: jest.fn(),
getAlerts: jest.fn(),
getFields: jest.fn(),
getMappings: jest.fn(),
getUserActions: jest.fn(),
update: jest.fn(),
updateAlertsStatus: jest.fn(),
});
@ -47,10 +50,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ({
alertsService: jest.Mocked<AlertServiceContract>;
};
}> => {
const actionsMock = actionsClientMock.create();
actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions()));
const actionsMock = createActionsClient();
const log = loggingSystemMock.create().get('case');
const request = {} as KibanaRequest;
const response = kibanaResponseFactory;
const caseServicePlugin = new CaseService(log);
const caseConfigureServicePlugin = new CaseConfigureService(log);
@ -63,11 +66,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({
const connectorMappingsService = await connectorMappingsServicePlugin.setup();
const userActionService = {
postUserActions: jest.fn(),
getUserActions: jest.fn(),
postUserActions: jest.fn(),
};
const alertsService = { initialize: jest.fn(), updateAlertsStatus: jest.fn() };
const alertsService = {
initialize: jest.fn(),
updateAlertsStatus: jest.fn(),
getAlerts: jest.fn(),
};
const context = {
core: {
@ -89,6 +96,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({
const caseClient = createCaseClient({
savedObjectsClient,
request,
response,
caseService,
caseConfigureService,
connectorMappingsService,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server';
import { KibanaRequest, KibanaResponseFactory, SavedObjectsClientContract } from 'kibana/server';
import { ActionsClient } from '../../../actions/server';
import {
CasePostRequest,
@ -16,6 +16,7 @@ import {
CommentRequest,
ConnectorMappingsAttributes,
GetFieldsResponse,
CaseUserActionsResponse,
} from '../../common/api';
import {
CaseConfigureServiceSetup,
@ -25,6 +26,7 @@ import {
} from '../services';
import { ConnectorMappingsServiceSetup } from '../services/connector_mappings';
import type { CasesRequestHandlerContext } from '../types';
import { CaseClientGetAlertsResponse } from './alerts/types';
export interface CaseClientCreate {
theCase: CasePostRequest;
@ -35,6 +37,18 @@ export interface CaseClientUpdate {
cases: CasesPatchRequest;
}
export interface CaseClientGet {
id: string;
includeComments?: boolean;
}
export interface CaseClientPush {
actionsClient: ActionsClient;
caseClient: CaseClient;
caseId: string;
connectorId: string;
}
export interface CaseClientAddComment {
caseClient: CaseClient;
caseId: string;
@ -46,11 +60,27 @@ export interface CaseClientUpdateAlertsStatus {
status: CaseStatuses;
}
export interface CaseClientGetAlerts {
ids: string[];
}
export interface CaseClientGetUserActions {
caseId: string;
}
export interface MappingsClient {
actionsClient: ActionsClient;
caseClient: CaseClient;
connectorId: string;
connectorType: string;
}
export interface CaseClientFactoryArguments {
caseConfigureService: CaseConfigureServiceSetup;
caseService: CaseServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
request: KibanaRequest;
response: KibanaResponseFactory;
savedObjectsClient: SavedObjectsClientContract;
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
@ -65,15 +95,22 @@ export interface ConfigureFields {
export interface CaseClient {
addComment: (args: CaseClientAddComment) => Promise<CaseResponse>;
create: (args: CaseClientCreate) => Promise<CaseResponse>;
get: (args: CaseClientGet) => Promise<CaseResponse>;
getAlerts: (args: CaseClientGetAlerts) => Promise<CaseClientGetAlertsResponse>;
getFields: (args: ConfigureFields) => Promise<GetFieldsResponse>;
getMappings: (args: MappingsClient) => Promise<ConnectorMappingsAttributes[]>;
getUserActions: (args: CaseClientGetUserActions) => Promise<CaseUserActionsResponse>;
push: (args: CaseClientPush) => Promise<CaseResponse>;
update: (args: CaseClientUpdate) => Promise<CasesResponse>;
updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise<void>;
}
export interface MappingsClient {
actionsClient: ActionsClient;
caseClient: CaseClient;
connectorId: string;
connectorType: string;
}
export type CaseClientFactoryMethod = (
factoryArgs: CaseClientFactoryArguments
) => (methodArgs: any) => Promise<any>;
export type CaseClientMethods = keyof CaseClient;
export type CaseClientFactoryMethods = {
[K in CaseClientMethods]: CaseClientFactoryMethod;
};

View file

@ -0,0 +1,31 @@
/*
* 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 { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types';
import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api';
import { CaseClientGetUserActions, CaseClientFactoryArguments } from '../types';
export const get = ({
savedObjectsClient,
userActionService,
}: CaseClientFactoryArguments) => async ({
caseId,
}: CaseClientGetUserActions): Promise<CaseUserActionsResponse> => {
const userActions = await userActionService.getUserActions({
client: savedObjectsClient,
caseId,
});
return CaseUserActionsResponseRt.encode(
userActions.saved_objects.map((ua) => ({
...ua.attributes,
action_id: ua.id,
case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
}))
);
};

View file

@ -7,7 +7,7 @@
import { curry } from 'lodash';
import { KibanaRequest } from 'kibana/server';
import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server';
import { ActionTypeExecutorResult } from '../../../../actions/common';
import { CasePatchRequest, CasePostRequest } from '../../../common/api';
import { createCaseClient } from '../../client';
@ -73,6 +73,7 @@ async function executor(
const caseClient = createCaseClient({
savedObjectsClient,
request: {} as KibanaRequest,
response: kibanaResponseFactory,
caseService,
caseConfigureService,
connectorMappingsService,

View file

@ -5,43 +5,14 @@
* 2.0.
*/
import { Logger } from 'kibana/server';
import {
ActionTypeConfig,
ActionTypeSecrets,
ActionTypeParams,
ActionType,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../actions/server/types';
import {
CaseServiceSetup,
CaseConfigureServiceSetup,
CaseUserActionServiceSetup,
ConnectorMappingsServiceSetup,
AlertServiceContract,
} from '../services';
import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types';
import { getActionType as getCaseConnector } from './case';
import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter';
import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter';
import { jiraExternalServiceFormatter } from './jira/external_service_formatter';
import { resilientExternalServiceFormatter } from './resilient/external_service_formatter';
export interface GetActionTypeParams {
logger: Logger;
caseService: CaseServiceSetup;
caseConfigureService: CaseConfigureServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
}
export interface RegisterConnectorsArgs extends GetActionTypeParams {
actionsRegisterType<
Config extends ActionTypeConfig = ActionTypeConfig,
Secrets extends ActionTypeSecrets = ActionTypeSecrets,
Params extends ActionTypeParams = ActionTypeParams,
ExecutorResultData = void
>(
actionType: ActionType<Config, Secrets, Params, ExecutorResultData>
): void;
}
export * from './types';
export const registerConnectors = ({
actionsRegisterType,
@ -63,3 +34,10 @@ export const registerConnectors = ({
})
);
};
export const externalServiceFormatters: ExternalServiceFormatterMapper = {
'.servicenow': serviceNowITSMExternalServiceFormatter,
'.servicenow-sir': serviceNowSIRExternalServiceFormatter,
'.jira': jiraExternalServiceFormatter,
'.resilient': resilientExternalServiceFormatter,
};

View file

@ -0,0 +1,35 @@
/*
* 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 { CaseResponse } from '../../../common/api';
import { jiraExternalServiceFormatter } from './external_service_formatter';
describe('Jira formatter', () => {
const theCase = {
tags: ['tag'],
connector: { fields: { priority: 'High', issueType: 'Task', parent: null } },
} as CaseResponse;
it('it formats correctly', async () => {
const res = await jiraExternalServiceFormatter.format(theCase, []);
expect(res).toEqual({ ...theCase.connector.fields, labels: theCase.tags });
});
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { tags: ['tag'], connector: { fields: null } } as CaseResponse;
const res = await jiraExternalServiceFormatter.format(invalidFields, []);
expect(res).toEqual({ priority: null, issueType: null, parent: null, labels: theCase.tags });
});
it('it replace white spaces with hyphens on tags', async () => {
const res = await jiraExternalServiceFormatter.format(
{ ...theCase, tags: ['a tag with spaces'] },
[]
);
expect(res).toEqual({ ...theCase.connector.fields, labels: ['a-tag-with-spaces'] });
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common/api';
import { ExternalServiceFormatter } from '../types';
interface ExternalServiceParams extends JiraFieldsType {
labels: string[];
}
const format: ExternalServiceFormatter<ExternalServiceParams>['format'] = (theCase) => {
const { priority = null, issueType = null, parent = null } =
(theCase.connector.fields as ConnectorJiraTypeFields['fields']) ?? {};
return {
priority,
// Jira do not allows empty spaces on labels. We replace white spaces with hyphens
labels: theCase.tags.map((tag) => tag.replace(/\s+/g, '-')),
issueType,
parent,
};
};
export const jiraExternalServiceFormatter: ExternalServiceFormatter<ExternalServiceParams> = {
format,
};

View file

@ -0,0 +1,26 @@
/*
* 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 { CaseResponse } from '../../../common/api';
import { resilientExternalServiceFormatter } from './external_service_formatter';
describe('IBM Resilient formatter', () => {
const theCase = {
connector: { fields: { incidentTypes: ['2'], severityCode: '2' } },
} as CaseResponse;
it('it formats correctly', async () => {
const res = await resilientExternalServiceFormatter.format(theCase, []);
expect(res).toEqual({ ...theCase.connector.fields });
});
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { tags: ['a tag'], connector: { fields: null } } as CaseResponse;
const res = await resilientExternalServiceFormatter.format(invalidFields, []);
expect(res).toEqual({ incidentTypes: null, severityCode: null });
});
});

View file

@ -0,0 +1,19 @@
/*
* 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 { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common/api';
import { ExternalServiceFormatter } from '../types';
const format: ExternalServiceFormatter<ResilientFieldsType>['format'] = (theCase) => {
const { incidentTypes = null, severityCode = null } =
(theCase.connector.fields as ConnectorResillientTypeFields['fields']) ?? {};
return { incidentTypes, severityCode };
};
export const resilientExternalServiceFormatter: ExternalServiceFormatter<ResilientFieldsType> = {
format,
};

View file

@ -0,0 +1,19 @@
/*
* 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 { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common/api';
import { ExternalServiceFormatter } from '../types';
const format: ExternalServiceFormatter<ServiceNowITSMFieldsType>['format'] = (theCase) => {
const { severity = null, urgency = null, impact = null } =
(theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {};
return { severity, urgency, impact };
};
export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter<ServiceNowITSMFieldsType> = {
format,
};

View file

@ -0,0 +1,26 @@
/*
* 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 { CaseResponse } from '../../../common/api';
import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter';
describe('ITSM formatter', () => {
const theCase = {
connector: { fields: { severity: '2', urgency: '2', impact: '2' } },
} as CaseResponse;
it('it formats correctly', async () => {
const res = await serviceNowITSMExternalServiceFormatter.format(theCase, []);
expect(res).toEqual(theCase.connector.fields);
});
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { connector: { fields: null } } as CaseResponse;
const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []);
expect(res).toEqual({ severity: null, urgency: null, impact: null });
});
});

View file

@ -0,0 +1,164 @@
/*
* 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 { CaseResponse } from '../../../common/api';
import { serviceNowSIRExternalServiceFormatter } from './sir_formatter';
describe('ITSM formatter', () => {
const theCase = {
connector: {
fields: {
destIp: true,
sourceIp: true,
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malwareHash: true,
malwareUrl: true,
priority: '2 - High',
},
},
} as CaseResponse;
it('it formats correctly without alerts', async () => {
const res = await serviceNowSIRExternalServiceFormatter.format(theCase, []);
expect(res).toEqual({
dest_ip: null,
source_ip: null,
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: null,
malware_url: null,
priority: '2 - High',
});
});
it('it formats correctly when fields do not exist ', async () => {
const invalidFields = { connector: { fields: null } } as CaseResponse;
const res = await serviceNowSIRExternalServiceFormatter.format(invalidFields, []);
expect(res).toEqual({
dest_ip: null,
source_ip: null,
category: null,
subcategory: null,
malware_hash: null,
malware_url: null,
priority: null,
});
});
it('it formats correctly with alerts', async () => {
const alerts = [
{
id: 'alert-1',
index: 'index-1',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
{
id: 'alert-2',
index: 'index-2',
destination: { ip: '192.168.1.4' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' },
},
url: { full: 'https://attack.com/api' },
},
];
const res = await serviceNowSIRExternalServiceFormatter.format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.1,192.168.1.4',
source_ip: '192.168.1.2,192.168.1.3',
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash:
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
malware_url: 'https://attack.com,https://attack.com/api',
priority: '2 - High',
});
});
it('it handles duplicates correctly', async () => {
const alerts = [
{
id: 'alert-1',
index: 'index-1',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
{
id: 'alert-2',
index: 'index-2',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com/api' },
},
];
const res = await serviceNowSIRExternalServiceFormatter.format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.1',
source_ip: '192.168.1.2,192.168.1.3',
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
malware_url: 'https://attack.com,https://attack.com/api',
priority: '2 - High',
});
});
it('it formats correctly when field is not selected', async () => {
const alerts = [
{
id: 'alert-1',
index: 'index-1',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.2' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com' },
},
{
id: 'alert-2',
index: 'index-2',
destination: { ip: '192.168.1.1' },
source: { ip: '192.168.1.3' },
file: {
hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' },
},
url: { full: 'https://attack.com/api' },
},
];
const newCase = {
...theCase,
connector: { fields: { ...theCase.connector.fields, destIp: false, malwareHash: false } },
} as CaseResponse;
const res = await serviceNowSIRExternalServiceFormatter.format(newCase, alerts);
expect(res).toEqual({
dest_ip: null,
source_ip: '192.168.1.2,192.168.1.3',
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: null,
malware_url: 'https://attack.com,https://attack.com/api',
priority: '2 - High',
});
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 { get } from 'lodash/fp';
import { ConnectorServiceNowSIRTypeFields } from '../../../common/api';
import { ExternalServiceFormatter } from '../types';
interface ExternalServiceParams {
dest_ip: string | null;
source_ip: string | null;
category: string | null;
subcategory: string | null;
malware_hash: string | null;
malware_url: string | null;
priority: string | null;
}
type SirFieldKey = 'dest_ip' | 'source_ip' | 'malware_hash' | 'malware_url';
type AlertFieldMappingAndValues = Record<
string,
{ alertPath: string; sirFieldKey: SirFieldKey; add: boolean }
>;
const format: ExternalServiceFormatter<ExternalServiceParams>['format'] = (theCase, alerts) => {
const {
destIp = null,
sourceIp = null,
category = null,
subcategory = null,
malwareHash = null,
malwareUrl = null,
priority = null,
} = (theCase.connector.fields as ConnectorServiceNowSIRTypeFields['fields']) ?? {};
const alertFieldMapping: AlertFieldMappingAndValues = {
destIp: { alertPath: 'destination.ip', sirFieldKey: 'dest_ip', add: !!destIp },
sourceIp: { alertPath: 'source.ip', sirFieldKey: 'source_ip', add: !!sourceIp },
malwareHash: { alertPath: 'file.hash.sha256', sirFieldKey: 'malware_hash', add: !!malwareHash },
malwareUrl: { alertPath: 'url.full', sirFieldKey: 'malware_url', add: !!malwareUrl },
};
const manageDuplicate: Record<SirFieldKey, Set<string>> = {
dest_ip: new Set(),
source_ip: new Set(),
malware_hash: new Set(),
malware_url: new Set(),
};
let sirFields: Record<SirFieldKey, string | null> = {
dest_ip: null,
source_ip: null,
malware_hash: null,
malware_url: null,
};
const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter(
(key) => alertFieldMapping[key].add
);
if (fieldsToAdd.length > 0) {
sirFields = alerts.reduce<Record<SirFieldKey, string | null>>((acc, alert) => {
fieldsToAdd.forEach((alertField) => {
const field = get(alertFieldMapping[alertField].alertPath, alert);
if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) {
manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field);
acc = {
...acc,
[alertFieldMapping[alertField].sirFieldKey]: `${
acc[alertFieldMapping[alertField].sirFieldKey] != null
? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}`
: field
}`,
};
}
});
return acc;
}, sirFields);
}
return {
...sirFields,
category,
subcategory,
priority,
};
};
export const serviceNowSIRExternalServiceFormatter: ExternalServiceFormatter<ExternalServiceParams> = {
format,
};

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger } from 'kibana/server';
import {
ActionTypeConfig,
ActionTypeSecrets,
ActionTypeParams,
ActionType,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../actions/server/types';
import { CaseResponse, ConnectorTypes } from '../../common/api';
import { CaseClientGetAlertsResponse } from '../client/alerts/types';
import {
CaseServiceSetup,
CaseConfigureServiceSetup,
CaseUserActionServiceSetup,
ConnectorMappingsServiceSetup,
AlertServiceContract,
} from '../services';
export interface GetActionTypeParams {
logger: Logger;
caseService: CaseServiceSetup;
caseConfigureService: CaseConfigureServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
}
export interface RegisterConnectorsArgs extends GetActionTypeParams {
actionsRegisterType<
Config extends ActionTypeConfig = ActionTypeConfig,
Secrets extends ActionTypeSecrets = ActionTypeSecrets,
Params extends ActionTypeParams = ActionTypeParams,
ExecutorResultData = void
>(
actionType: ActionType<Config, Secrets, Params, ExecutorResultData>
): void;
}
export type FormatterConnectorTypes = Exclude<ConnectorTypes, ConnectorTypes.none>;
export interface ExternalServiceFormatter<TExternalServiceParams = {}> {
format: (theCase: CaseResponse, alerts: CaseClientGetAlertsResponse) => TExternalServiceParams;
}
export type ExternalServiceFormatterMapper = {
[x in FormatterConnectorTypes]: ExternalServiceFormatter;
};

View file

@ -5,7 +5,13 @@
* 2.0.
*/
import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server';
import {
IContextProvider,
KibanaRequest,
KibanaResponseFactory,
Logger,
PluginInitializerContext,
} from 'kibana/server';
import { CoreSetup, CoreStart } from 'src/core/server';
import { SecurityPluginSetup } from '../../security/server';
@ -123,11 +129,13 @@ export class CasePlugin {
const getCaseClientWithRequestAndContext = async (
context: CasesRequestHandlerContext,
request: KibanaRequest
request: KibanaRequest,
response: KibanaResponseFactory
) => {
return createCaseClient({
savedObjectsClient: core.savedObjects.getScopedClient(request),
request,
response,
caseService: this.caseService!,
caseConfigureService: this.caseConfigureService!,
connectorMappingsService: this.connectorMappingsService!,
@ -161,7 +169,7 @@ export class CasePlugin {
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
}): IContextProvider<CasesRequestHandlerContext, 'case'> => {
return async (context, request) => {
return async (context, request, response) => {
const [{ savedObjects }] = await core.getStartServices();
return {
getCaseClient: () => {
@ -172,8 +180,9 @@ export class CasePlugin {
connectorMappingsService,
userActionService,
alertsService,
request,
context,
request,
response,
});
},
};

View file

@ -17,6 +17,7 @@ import {
CASE_SAVED_OBJECT,
CASE_CONFIGURE_SAVED_OBJECT,
CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
} from '../../../saved_object_types';
export const createMockSavedObjectsRepository = ({
@ -24,11 +25,13 @@ export const createMockSavedObjectsRepository = ({
caseCommentSavedObject = [],
caseConfigureSavedObject = [],
caseMappingsSavedObject = [],
caseUserActionsSavedObject = [],
}: {
caseSavedObject?: any[];
caseCommentSavedObject?: any[];
caseConfigureSavedObject?: any[];
caseMappingsSavedObject?: any[];
caseUserActionsSavedObject?: any[];
} = {}) => {
const mockSavedObjectsClientContract = ({
bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => {
@ -57,6 +60,7 @@ export const createMockSavedObjectsRepository = ({
}),
};
}),
bulkCreate: jest.fn(),
bulkUpdate: jest.fn((objects: Array<SavedObjectsBulkUpdateObject<unknown>>) => {
return {
saved_objects: objects.map(({ id, type, attributes }) => {
@ -136,6 +140,16 @@ export const createMockSavedObjectsRepository = ({
saved_objects: caseCommentSavedObject,
};
}
if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) {
return {
page: 1,
per_page: 5,
total: caseUserActionsSavedObject.length,
saved_objects: caseUserActionsSavedObject,
};
}
return {
page: 1,
per_page: 5,

View file

@ -10,3 +10,4 @@ export { createMockSavedObjectsRepository } from './create_mock_so_repository';
export { createRouteContext } from './route_contexts';
export { authenticationMock } from './authc_mock';
export { createRoute } from './mock_router';
export { createActionsClient } from './mock_actions_client';

View file

@ -0,0 +1,34 @@
/*
* 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 { SavedObjectsErrorHelpers } from 'src/core/server';
import { actionsClientMock } from '../../../../../actions/server/mocks';
import {
getActions,
getActionTypes,
getActionExecuteResults,
} from '../__mocks__/request_responses';
export const createActionsClient = () => {
const actionsMock = actionsClientMock.create();
actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions()));
actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes()));
actionsMock.get.mockImplementation(({ id }) => {
const actions = getActions();
const action = actions.find((a) => a.id === id);
if (action) {
return Promise.resolve(action);
} else {
return Promise.reject(SavedObjectsErrorHelpers.createGenericNotFoundError('action', id));
}
});
actionsMock.execute.mockImplementation(({ actionId }) =>
Promise.resolve(getActionExecuteResults(actionId))
);
return actionsMock;
};

View file

@ -8,6 +8,7 @@
import { SavedObject } from 'kibana/server';
import {
CaseStatuses,
CaseUserActionAttributes,
CommentAttributes,
CommentType,
ConnectorMappings,
@ -15,7 +16,10 @@ import {
ESCaseAttributes,
ESCasesConfigureAttributes,
} from '../../../../common/api';
import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../../saved_object_types';
import {
CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
} from '../../../saved_object_types';
import { mappings } from '../../../client/configure/mock';
export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
@ -424,3 +428,44 @@ export const mockCaseMappings: Array<SavedObject<ConnectorMappings>> = [
references: [],
},
];
export const mockUserActions: Array<SavedObject<CaseUserActionAttributes>> = [
{
type: CASE_USER_ACTION_SAVED_OBJECT,
id: 'mock-user-actions-1',
attributes: {
action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'],
action: 'create',
action_at: '2021-02-03T17:41:03.771Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value:
'{"title":"A case","tags":["case"],"description":"Yeah!","connector":{"id":"connector-od","name":"My Connector","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}',
old_value: null,
},
version: 'WzYsMV0=',
references: [],
},
{
type: CASE_USER_ACTION_SAVED_OBJECT,
id: 'mock-user-actions-2',
attributes: {
action_field: ['comment'],
action: 'create',
action_at: '2021-02-03T17:44:21.067Z',
action_by: {
email: 'elastic@elastic.co',
full_name: 'Elastic',
username: 'elastic',
},
new_value:
'{"type":"alert","alertId":"cec3da90fb37a44407145adf1593f3b0d5ad94c4654201f773d63b5d4706128e","index":".siem-signals-default-000008"}',
old_value: null,
},
version: 'WzYsMV0=',
references: [],
},
];

View file

@ -5,24 +5,25 @@
* 2.0.
*/
import { KibanaRequest } from 'src/core/server';
import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks';
import { actionsClientMock } from '../../../../../actions/server/mocks';
import { KibanaRequest, kibanaResponseFactory } from '../../../../../../../src/core/server';
import {
loggingSystemMock,
elasticsearchServiceMock,
} from '../../../../../../../src/core/server/mocks';
import { createCaseClient } from '../../../client';
import {
AlertService,
CaseService,
CaseConfigureService,
ConnectorMappingsService,
CaseUserActionService,
} from '../../../services';
import { getActions, getActionTypes } from '../__mocks__/request_responses';
import { authenticationMock } from '../__fixtures__';
import type { CasesRequestHandlerContext } from '../../../types';
import { createActionsClient } from './mock_actions_client';
export const createRouteContext = async (client: any, badAuth = false) => {
const actionsMock = actionsClientMock.create();
actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions()));
actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes()));
const actionsMock = createActionsClient();
const log = loggingSystemMock.create().get('case');
const esClientMock = elasticsearchServiceMock.createClusterClient();
@ -30,11 +31,13 @@ export const createRouteContext = async (client: any, badAuth = false) => {
const caseServicePlugin = new CaseService(log);
const caseConfigureServicePlugin = new CaseConfigureService(log);
const connectorMappingsServicePlugin = new ConnectorMappingsService(log);
const caseUserActionsServicePlugin = new CaseUserActionService(log);
const caseService = await caseServicePlugin.setup({
authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(),
});
const caseConfigureService = await caseConfigureServicePlugin.setup();
const userActionService = await caseUserActionsServicePlugin.setup();
const alertsService = new AlertService();
alertsService.initialize(esClientMock);
@ -59,16 +62,14 @@ export const createRouteContext = async (client: any, badAuth = false) => {
const caseClient = createCaseClient({
savedObjectsClient: client,
request: {} as KibanaRequest,
response: kibanaResponseFactory,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService: {
postUserActions: jest.fn(),
getUserActions: jest.fn(),
},
userActionService,
alertsService,
context,
});
return context;
return { context, services: { userActionService } };
};

View file

@ -10,11 +10,9 @@ import {
CasePostRequest,
CasesConfigureRequest,
ConnectorTypes,
PostPushRequest,
} from '../../../../common/api';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { FindActionResult } from '../../../../../actions/server/types';
import { params } from '../cases/configure/mock';
export const newCase: CasePostRequest = {
title: 'My new case',
@ -74,6 +72,16 @@ export const getActions = (): FindActionResult[] => [
isPreconfigured: false,
referencedByCount: 0,
},
{
id: 'for-mock-case-id-3',
actionTypeId: '.jira',
name: 'For mock case id 3',
config: {
apiUrl: 'https://elastic.jira.com',
},
isPreconfigured: false,
referencedByCount: 0,
},
];
export const getActionTypes = (): ActionTypeConnector[] => [
@ -119,6 +127,18 @@ export const getActionTypes = (): ActionTypeConnector[] => [
},
];
export const getActionExecuteResults = (actionId = '123') => ({
status: 'ok' as const,
data: {
title: 'RJ2-200',
id: '10663',
pushedDate: '2020-12-17T00:32:40.738Z',
url: 'https://siem-kibana.atlassian.net/browse/RJ2-200',
comments: [],
},
actionId,
});
export const newConfiguration: CasesConfigureRequest = {
connector: {
id: '456',
@ -129,11 +149,6 @@ export const newConfiguration: CasesConfigureRequest = {
closure_type: 'close-by-pushing',
};
export const newPostPushRequest: PostPushRequest = {
params: params[ConnectorTypes.jira],
connector_type: ConnectorTypes.jira,
};
export const executePushResponse = {
status: 'ok',
data: {

View file

@ -33,14 +33,14 @@ describe('DELETE comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(204);
});
it(`returns an error when thrown from deleteComment service`, async () => {
@ -53,14 +53,14 @@ describe('DELETE comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
});
});

View file

@ -34,14 +34,14 @@ describe('GET comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
const myPayload = mockCaseComments.find((s) => s.id === 'mock-comment-1');
expect(myPayload).not.toBeUndefined();
@ -59,13 +59,13 @@ describe('GET comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
});
});

View file

@ -41,14 +41,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual(
'Update my comment'
@ -71,14 +71,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual(
'new-id'
@ -102,14 +102,14 @@ describe('PATCH comment', () => {
body: requestAttributes,
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -130,14 +130,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -161,14 +161,14 @@ describe('PATCH comment', () => {
body: requestAttributes,
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -190,14 +190,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -219,14 +219,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
expect(response.payload.message).toEqual('You cannot change the type of the comment.');
@ -247,14 +247,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(409);
});
@ -273,14 +273,14 @@ describe('PATCH comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
expect(response.payload.isBoom).toEqual(true);
});

View file

@ -43,14 +43,14 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual(
'mock-comment'
@ -71,14 +71,14 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual(
'mock-comment'
@ -95,14 +95,14 @@ describe('POST comment', () => {
body: {},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
});
@ -124,14 +124,14 @@ describe('POST comment', () => {
body: requestAttributes,
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -152,14 +152,14 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -183,14 +183,14 @@ describe('POST comment', () => {
body: requestAttributes,
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -212,14 +212,14 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
}
@ -238,14 +238,14 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
expect(response.payload.isBoom).toEqual(true);
});
@ -262,14 +262,14 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
});
@ -289,7 +289,7 @@ describe('POST comment', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
@ -297,7 +297,7 @@ describe('POST comment', () => {
true
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({
comment: 'Wow, good luck catching that bad meanie!',

View file

@ -34,7 +34,7 @@ describe('GET configuration', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -57,7 +57,7 @@ describe('GET configuration', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }],
caseMappingsSavedObject: mockCaseMappings,
@ -98,7 +98,7 @@ describe('GET configuration', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [],
})
@ -116,7 +116,7 @@ describe('GET configuration', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }],
})
@ -133,7 +133,7 @@ describe('GET configuration', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: [],

View file

@ -33,9 +33,9 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps
throw Boom.badRequest('RouteHandlerContext is not registered for cases');
}
const caseClient = context.case.getCaseClient();
const actionsClient = await context.actions?.getActionsClient();
const actionsClient = context.actions?.getActionsClient();
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
throw Boom.notFound('Action client not found');
}
try {
mappings = await caseClient.getMappings({

View file

@ -32,7 +32,7 @@ describe('GET connectors', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -54,7 +54,7 @@ describe('GET connectors', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -106,6 +106,16 @@ describe('GET connectors', () => {
isPreconfigured: false,
referencedByCount: 0,
},
{
id: 'for-mock-case-id-3',
actionTypeId: '.jira',
name: 'For mock case id 3',
config: {
apiUrl: 'https://elastic.jira.com',
},
isPreconfigured: false,
referencedByCount: 0,
},
]);
});
@ -115,7 +125,7 @@ describe('GET connectors', () => {
method: 'get',
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,

View file

@ -14,18 +14,15 @@ import { FindActionResult } from '../../../../../../actions/server/types';
import {
CASE_CONFIGURE_CONNECTORS_URL,
SERVICENOW_ACTION_TYPE_ID,
JIRA_ACTION_TYPE_ID,
RESILIENT_ACTION_TYPE_ID,
SUPPORTED_CONNECTORS,
} from '../../../../../common/constants';
const isConnectorSupported = (
action: FindActionResult,
actionTypes: Record<string, ActionType>
): boolean =>
[SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes(
action.actionTypeId
) && actionTypes[action.actionTypeId]?.enabledInLicense;
SUPPORTED_CONNECTORS.includes(action.actionTypeId) &&
actionTypes[action.actionTypeId]?.enabledInLicense;
/*
* Be aware that this api will only return 20 connectors
@ -39,10 +36,10 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) {
},
async (context, request, response) => {
try {
const actionsClient = await context.actions?.getActionsClient();
const actionsClient = context.actions?.getActionsClient();
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
throw Boom.notFound('Action client not found');
}
const actionTypes = (await actionsClient.listTypes()).reduce(

View file

@ -1,76 +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 {
ServiceConnectorCaseParams,
ServiceConnectorCommentParams,
ConnectorMappingsAttributes,
ConnectorTypes,
} from '../../../../../common/api/connectors';
export const updateUser = {
updatedAt: '2020-03-13T08:34:53.450Z',
updatedBy: { fullName: 'Another User', username: 'another' },
};
const entity = {
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
};
export const comment: ServiceConnectorCommentParams = {
comment: 'first comment',
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
...entity,
};
export const defaultPipes = ['informationCreated'];
const basicParams = {
comments: [comment],
description: 'a description',
title: 'a title',
savedObjectId: '1231231231232',
externalId: null,
};
export const params = {
[ConnectorTypes.jira]: {
...basicParams,
issueType: '10003',
priority: 'Highest',
parent: '5002',
...entity,
} as ServiceConnectorCaseParams,
[ConnectorTypes.resilient]: {
...basicParams,
incidentTypes: ['10003'],
severityCode: '1',
...entity,
} as ServiceConnectorCaseParams,
[ConnectorTypes.servicenow]: {
...basicParams,
impact: '3',
severity: '1',
urgency: '2',
...entity,
} as ServiceConnectorCaseParams,
[ConnectorTypes.none]: {},
};
export const mappings: ConnectorMappingsAttributes[] = [
{
source: 'title',
target: 'short_description',
action_type: 'overwrite',
},
{
source: 'description',
target: 'description',
action_type: 'append',
},
{
source: 'comments',
target: 'comments',
action_type: 'append',
},
];

View file

@ -42,7 +42,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -76,7 +76,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -115,7 +115,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -153,7 +153,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: [],
@ -193,7 +193,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [],
})
@ -215,7 +215,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -243,7 +243,7 @@ describe('PATCH configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,

View file

@ -66,7 +66,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
throw Boom.badRequest('RouteHandlerContext is not registered for cases');
}
const caseClient = context.case.getCaseClient();
const actionsClient = await context.actions?.getActionsClient();
const actionsClient = context.actions?.getActionsClient();
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
}

View file

@ -40,7 +40,7 @@ describe('POST configuration', () => {
body: newConfiguration,
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -73,7 +73,7 @@ describe('POST configuration', () => {
body: newConfiguration,
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: [],
@ -113,7 +113,7 @@ describe('POST configuration', () => {
body: newConfiguration,
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -154,7 +154,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -180,7 +180,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -206,7 +206,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -232,7 +232,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -258,7 +258,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -282,7 +282,7 @@ describe('POST configuration', () => {
caseMappingsSavedObject: mockCaseMappings,
});
const context = await createRouteContext(savedObjectRepository);
const { context } = await createRouteContext(savedObjectRepository);
const res = await routeHandler(context, req, kibanaResponseFactory);
@ -302,7 +302,7 @@ describe('POST configuration', () => {
caseMappingsSavedObject: mockCaseMappings,
});
const context = await createRouteContext(savedObjectRepository);
const { context } = await createRouteContext(savedObjectRepository);
const res = await routeHandler(context, req, kibanaResponseFactory);
@ -325,7 +325,7 @@ describe('POST configuration', () => {
caseMappingsSavedObject: mockCaseMappings,
});
const context = await createRouteContext(savedObjectRepository);
const { context } = await createRouteContext(savedObjectRepository);
const res = await routeHandler(context, req, kibanaResponseFactory);
@ -341,7 +341,7 @@ describe('POST configuration', () => {
body: newConfiguration,
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }],
})
@ -359,7 +359,7 @@ describe('POST configuration', () => {
body: newConfiguration,
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-delete' }],
})
@ -384,7 +384,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -411,7 +411,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -437,7 +437,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
@ -459,7 +459,7 @@ describe('POST configuration', () => {
},
});
const context = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,

View file

@ -39,9 +39,9 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
throw Boom.badRequest('RouteHandlerContext is not registered for cases');
}
const caseClient = context.case.getCaseClient();
const actionsClient = await context.actions?.getActionsClient();
const actionsClient = context.actions?.getActionsClient();
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
throw Boom.notFound('Action client not found');
}
const client = context.core.savedObjects.client;
const query = pipe(

View file

@ -1,106 +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 { kibanaResponseFactory, RequestHandler } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import {
createMockSavedObjectsRepository,
createRoute,
createRouteContext,
mockCaseMappings,
} from '../../__fixtures__';
import { initPostPushToService } from './post_push_to_service';
import { executePushResponse, newPostPushRequest } from '../../__mocks__/request_responses';
import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants';
import type { CasesRequestHandlerContext } from '../../../../types';
describe('Post push to service', () => {
let routeHandler: RequestHandler<any, any, any>;
const req = httpServerMock.createKibanaRequest({
path: `${CASE_CONFIGURE_PUSH_URL}`,
method: 'post',
params: {
connector_id: '666',
},
body: newPostPushRequest,
});
let context: CasesRequestHandlerContext;
beforeAll(async () => {
routeHandler = await createRoute(initPostPushToService, 'post');
const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>;
spyOnDate.mockImplementation(() => ({
toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'),
}));
context = await createRouteContext(
createMockSavedObjectsRepository({
caseMappingsSavedObject: mockCaseMappings,
})
);
});
it('Happy path - posts success', async () => {
const betterContext = ({
...context,
actions: {
...context.actions,
getActionsClient: () => {
const actions = context!.actions!.getActionsClient();
return {
...actions,
execute: jest.fn().mockImplementation(({ actionId }) => {
return {
status: 'ok',
data: {
title: 'RJ2-200',
id: '10663',
pushedDate: '2020-12-17T00:32:40.738Z',
url: 'https://siem-kibana.atlassian.net/browse/RJ2-200',
comments: [],
},
actionId,
};
}),
};
},
},
} as unknown) as CasesRequestHandlerContext;
const res = await routeHandler(betterContext, req, kibanaResponseFactory);
expect(res.status).toEqual(200);
expect(res.payload).toEqual({
...executePushResponse,
actionId: '666',
});
});
it('Unhappy path - context case missing', async () => {
const betterContext = ({
...context,
case: null,
} as unknown) as CasesRequestHandlerContext;
const res = await routeHandler(betterContext, req, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload.isBoom).toBeTruthy();
expect(res.payload.output.payload.message).toEqual(
'RouteHandlerContext is not registered for cases'
);
});
it('Unhappy path - context actions missing', async () => {
const betterContext = ({
...context,
actions: null,
} as unknown) as CasesRequestHandlerContext;
const res = await routeHandler(betterContext, req, kibanaResponseFactory);
expect(res.status).toEqual(404);
expect(res.payload.isBoom).toBeTruthy();
expect(res.payload.output.payload.message).toEqual('Action client have not been found');
});
});

View file

@ -1,81 +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 { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import Boom from '@hapi/boom';
import { RouteDeps } from '../../types';
import { escapeHatch, wrapError } from '../../utils';
import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants';
import {
ConnectorRequestParamsRt,
PostPushRequestRt,
throwErrors,
} from '../../../../../common/api';
import { mapIncident } from './utils';
export function initPostPushToService({ router }: RouteDeps) {
router.post(
{
path: CASE_CONFIGURE_PUSH_URL,
validate: {
params: escapeHatch,
body: escapeHatch,
},
},
async (context, request, response) => {
try {
if (!context.case) {
throw Boom.badRequest('RouteHandlerContext is not registered for cases');
}
const caseClient = context.case.getCaseClient();
const actionsClient = await context.actions?.getActionsClient();
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
}
const params = pipe(
ConnectorRequestParamsRt.decode(request.params),
fold(throwErrors(Boom.badRequest), identity)
);
const body = pipe(
PostPushRequestRt.decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);
const myConnectorMappings = await caseClient.getMappings({
actionsClient,
caseClient,
connectorId: params.connector_id,
connectorType: body.connector_type,
});
const res = await mapIncident(
actionsClient,
params.connector_id,
body.connector_type,
myConnectorMappings,
body.params
);
const pushRes = await actionsClient.execute({
actionId: params.connector_id,
params: {
subAction: 'pushToService',
subActionParams: res,
},
});
return response.ok({
body: pushRes,
});
} catch (error) {
return response.customError(wrapError(error));
}
}
);
}

View file

@ -33,14 +33,14 @@ describe('DELETE case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(204);
});
it(`returns an error when thrown from deleteCase service`, async () => {
@ -52,14 +52,14 @@ describe('DELETE case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
});
it(`returns an error when thrown from getAllCaseComments service`, async () => {
@ -71,14 +71,14 @@ describe('DELETE case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCasesErrorTriggerData,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
});
it(`returns an error when thrown from deleteComment service`, async () => {
@ -90,14 +90,14 @@ describe('DELETE case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCasesErrorTriggerData,
caseCommentSavedObject: mockCasesErrorTriggerData,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
});
});

View file

@ -30,13 +30,13 @@ describe('FIND all cases', () => {
method: 'get',
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases).toHaveLength(4);
// mockSavedObjectsRepository do not support filters and returns all cases every time.
@ -51,13 +51,13 @@ describe('FIND all cases', () => {
method: 'get',
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases[2].connector.id).toEqual('123');
});
@ -68,13 +68,13 @@ describe('FIND all cases', () => {
method: 'get',
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: [mockCaseNoConnectorId],
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases[0].connector.id).toEqual('none');
});
@ -85,14 +85,14 @@ describe('FIND all cases', () => {
method: 'get',
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: [mockCaseNoConnectorId],
caseConfigureSavedObject: mockCaseConfigure,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases[0].connector.id).toEqual('none');
});

View file

@ -40,13 +40,13 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
const savedObject = (mockCases.find(
(s) => s.id === 'mock-id-1'
) as unknown) as SavedObject<ESCaseAttributes>;
@ -71,13 +71,13 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
expect(response.payload.isBoom).toEqual(true);
@ -95,14 +95,14 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments).toHaveLength(5);
@ -120,13 +120,13 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCasesErrorTriggerData,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
});
@ -143,13 +143,13 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: [mockCaseNoConnectorId],
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector).toEqual({
@ -172,14 +172,14 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: [mockCaseNoConnectorId],
caseConfigureSavedObject: mockCaseConfigure,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector).toEqual({
@ -202,14 +202,14 @@ describe('GET case', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseConfigureSavedObject: mockCaseConfigure,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector).toEqual({

View file

@ -7,9 +7,8 @@
import { schema } from '@kbn/config-schema';
import { CaseResponseRt } from '../../../../common/api';
import { RouteDeps } from '../types';
import { flattenCaseSavedObject, wrapError } from '../utils';
import { wrapError } from '../utils';
import { CASE_DETAILS_URL } from '../../../../common/constants';
export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) {
@ -26,44 +25,17 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro
},
},
async (context, request, response) => {
if (!context.case) {
return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' });
}
const caseClient = context.case.getCaseClient();
const includeComments = JSON.parse(request.query.includeComments);
const id = request.params.case_id;
try {
const client = context.core.savedObjects.client;
const includeComments = JSON.parse(request.query.includeComments);
const [theCase] = await Promise.all([
caseService.getCase({
client,
caseId: request.params.case_id,
}),
]);
if (!includeComments) {
return response.ok({
body: CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: theCase,
})
),
});
}
const theComments = await caseService.getAllCaseComments({
client,
caseId: request.params.case_id,
options: {
sortField: 'created_at',
sortOrder: 'asc',
},
});
return response.ok({
body: CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: theCase,
comments: theComments.saved_objects,
totalComment: theComments.total,
})
),
body: await caseClient.get({ id, includeComments }),
});
} catch (error) {
return response.customError(wrapError(error));

View file

@ -44,13 +44,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
@ -97,14 +97,14 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseConfigureSavedObject: mockCaseConfigure,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
@ -151,13 +151,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
@ -204,13 +204,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: [mockCaseNoConnectorId],
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload[0].connector.id).toEqual('none');
});
@ -230,13 +230,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload[0].connector.id).toEqual('123');
});
@ -261,13 +261,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload[0].connector).toEqual({
id: '456',
@ -292,13 +292,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(409);
});
@ -317,14 +317,14 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseCommentSavedObject: mockCaseComments,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(406);
});
@ -343,13 +343,13 @@ describe('PATCH cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
expect(response.payload.isBoom).toEqual(true);
});

View file

@ -49,13 +49,13 @@ describe('POST cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.id).toEqual('mock-it');
expect(response.payload.status).toEqual('open');
@ -88,14 +88,14 @@ describe('POST cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseConfigureSavedObject: mockCaseConfigure,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector).toEqual({
id: '123',
@ -121,13 +121,13 @@ describe('POST cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
});
@ -146,13 +146,13 @@ describe('POST cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
});
@ -179,7 +179,7 @@ describe('POST cases', () => {
},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseConfigureSavedObject: mockCaseConfigure,
@ -187,7 +187,7 @@ describe('POST cases', () => {
true
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual({
closed_at: null,

View file

@ -13,63 +13,187 @@ import {
createRoute,
createRouteContext,
mockCases,
mockCaseConfigure,
mockCaseMappings,
mockUserActions,
mockCaseComments,
} from '../__fixtures__';
import { initPushCaseUserActionApi } from './push_case';
import { CASE_DETAILS_URL } from '../../../../common/constants';
import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects';
import { initPushCaseApi } from './push_case';
import { CasesRequestHandlerContext } from '../../../types';
import { getCasePushUrl } from '../../../../common/api/helpers';
describe('Push case', () => {
let routeHandler: RequestHandler<any, any, any>;
const mockDate = '2019-11-25T21:54:48.952Z';
const caseExternalServiceRequestBody = {
connector_id: 'connector_id',
connector_name: 'connector_name',
external_id: 'external_id',
external_title: 'external_title',
external_url: 'external_url',
};
const caseId = 'mock-id-3';
const connectorId = '123';
const path = getCasePushUrl(caseId, connectorId);
beforeAll(async () => {
routeHandler = await createRoute(initPushCaseUserActionApi, 'post');
routeHandler = await createRoute(initPushCaseApi, 'post');
const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>;
spyOnDate.mockImplementation(() => ({
toISOString: jest.fn().mockReturnValue(mockDate),
}));
});
it(`Pushes a case`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASE_DETAILS_URL}/_push`,
path,
method: 'post',
params: {
case_id: 'mock-id-3',
case_id: caseId,
connector_id: connectorId,
},
body: caseExternalServiceRequestBody,
body: {},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.external_service.pushed_at).toEqual(mockDate);
expect(response.payload.external_service.connector_id).toEqual('connector_id');
expect(response.payload.closed_at).toEqual(null);
expect(response.payload.external_service).toEqual({
connector_id: connectorId,
connector_name: 'ServiceNow',
external_id: '10663',
external_title: 'RJ2-200',
external_url: 'https://siem-kibana.atlassian.net/browse/RJ2-200',
pushed_at: mockDate,
pushed_by: {
email: 'd00d@awesome.com',
full_name: 'Awesome D00d',
username: 'awesome',
},
});
});
it(`Pushes a case and closes when closure_type: 'close-by-pushing'`, async () => {
it(`Pushes a case with comments`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASE_DETAILS_URL}/_push`,
path,
method: 'post',
params: {
case_id: 'mock-id-3',
case_id: caseId,
connector_id: connectorId,
},
body: caseExternalServiceRequestBody,
body: {},
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
caseCommentSavedObject: [mockCaseComments[0]],
})
);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.comments[0].pushed_at).toEqual(mockDate);
expect(response.payload.comments[0].pushed_by).toEqual({
email: 'd00d@awesome.com',
full_name: 'Awesome D00d',
username: 'awesome',
});
});
it(`Filters comments with type alert correctly`, async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
caseCommentSavedObject: [mockCaseComments[0], mockCaseComments[3]],
})
);
const caseClient = context.case.getCaseClient();
caseClient.getAlerts = jest.fn().mockResolvedValue([]);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] });
});
it(`Calls execute with correct arguments`, async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: 'for-mock-case-id-3',
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const actionsClient = context.actions.getActionsClient();
await routeHandler(context, request, kibanaResponseFactory);
expect(actionsClient.execute).toHaveBeenCalledWith({
actionId: 'for-mock-case-id-3',
params: {
subAction: 'pushToService',
subActionParams: {
incident: {
issueType: 'Task',
parent: null,
priority: 'High',
labels: ['LOLBins'],
summary: 'Another bad one (created at 2019-11-25T22:32:17.947Z by elastic)',
description:
'Oh no, a bad meanie going LOLBins all over the place! (created at 2019-11-25T22:32:17.947Z by elastic)',
externalId: null,
},
comments: [],
},
},
});
});
it(`Pushes a case and closes when closure_type: 'close-by-pushing'`, async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseUserActionsSavedObject: mockUserActions,
caseConfigureSavedObject: [
{
...mockCaseConfigure[0],
@ -82,30 +206,259 @@ describe('Push case', () => {
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.external_service.pushed_at).toEqual(mockDate);
expect(response.payload.external_service.connector_id).toEqual('connector_id');
expect(response.payload.closed_at).toEqual(mockDate);
});
it(`Returns an error if pushCaseUserAction throws`, async () => {
it(`post the correct user action`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASE_DETAILS_URL}/_push`,
path,
method: 'post',
body: {
notagoodbody: 'Throw an error',
params: {
case_id: caseId,
connector_id: connectorId,
},
body: {},
});
const theContext = await createRouteContext(
const { context, services } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
services.userActionService.postUserActions = jest.fn();
const postUserActions = services.userActionService.postUserActions as jest.Mock;
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(postUserActions.mock.calls[0][0].actions[0].attributes).toEqual({
action: 'push-to-service',
action_at: '2019-11-25T21:54:48.952Z',
action_by: {
email: 'd00d@awesome.com',
full_name: 'Awesome D00d',
username: 'awesome',
},
action_field: ['pushed'],
new_value:
'{"pushed_at":"2019-11-25T21:54:48.952Z","pushed_by":{"username":"awesome","full_name":"Awesome D00d","email":"d00d@awesome.com"},"connector_id":"123","connector_name":"ServiceNow","external_id":"10663","external_title":"RJ2-200","external_url":"https://siem-kibana.atlassian.net/browse/RJ2-200"}',
old_value: null,
});
});
it('Unhappy path - case id is missing', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const res = await routeHandler(context, request, kibanaResponseFactory);
expect(res.status).toEqual(400);
});
it('Unhappy path - connector id is missing', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const res = await routeHandler(context, request, kibanaResponseFactory);
expect(res.status).toEqual(400);
});
it('Unhappy path - case does not exists', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: 'not-exist',
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const res = await routeHandler(context, request, kibanaResponseFactory);
expect(res.status).toEqual(404);
});
it('Unhappy path - connector does not exists', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: 'not-exists',
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const res = await routeHandler(context, request, kibanaResponseFactory);
expect(res.status).toEqual(404);
});
it('Unhappy path - cannot push to a closed case', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: 'mock-id-4',
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const res = await routeHandler(context, request, kibanaResponseFactory);
expect(res.status).toEqual(409);
expect(res.payload.output.payload.message).toBe(
'This case Another bad one is closed. You can not pushed if the case is closed.'
);
});
it('Unhappy path - throws when external service returns an error', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const actionsClient = context.actions.getActionsClient();
(actionsClient.execute as jest.Mock).mockResolvedValue({
status: 'error',
});
const res = await routeHandler(context, request, kibanaResponseFactory);
expect(res.status).toEqual(424);
expect(res.payload.output.payload.message).toBe('Error pushing to service');
});
it('Unhappy path - context case missing', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const betterContext = ({
...context,
case: null,
} as unknown) as CasesRequestHandlerContext;
const res = await routeHandler(betterContext, request, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload).toEqual('RouteHandlerContext is not registered for cases');
});
it('Unhappy path - context actions missing', async () => {
const request = httpServerMock.createKibanaRequest({
path,
method: 'post',
params: {
case_id: caseId,
connector_id: connectorId,
},
body: {},
});
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
caseMappingsSavedObject: mockCaseMappings,
caseConfigureSavedObject: mockCaseConfigure,
caseUserActionsSavedObject: mockUserActions,
})
);
const betterContext = ({
...context,
actions: null,
} as unknown) as CasesRequestHandlerContext;
const res = await routeHandler(betterContext, request, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload).toEqual('Action client not found');
});
});

View file

@ -5,204 +5,51 @@
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import Boom from '@hapi/boom';
import isEmpty from 'lodash/isEmpty';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import {
flattenCaseSavedObject,
wrapError,
escapeHatch,
getCommentContextFromAttributes,
} from '../utils';
import { wrapError, escapeHatch } from '../utils';
import {
CaseExternalServiceRequestRt,
CaseResponseRt,
throwErrors,
CaseStatuses,
} from '../../../../common/api';
import { buildCaseUserActionItem } from '../../../services/user_actions/helpers';
import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api';
import { RouteDeps } from '../types';
import { CASE_DETAILS_URL } from '../../../../common/constants';
import { CASE_PUSH_URL } from '../../../../common/constants';
export function initPushCaseUserActionApi({
caseConfigureService,
caseService,
router,
userActionService,
}: RouteDeps) {
export function initPushCaseApi({ router }: RouteDeps) {
router.post(
{
path: `${CASE_DETAILS_URL}/_push`,
path: CASE_PUSH_URL,
validate: {
params: schema.object({
case_id: schema.string(),
}),
params: escapeHatch,
body: escapeHatch,
},
},
async (context, request, response) => {
try {
const client = context.core.savedObjects.client;
const actionsClient = await context.actions?.getActionsClient();
if (!context.case) {
return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' });
}
const caseId = request.params.case_id;
const query = pipe(
CaseExternalServiceRequestRt.decode(request.body),
const caseClient = context.case.getCaseClient();
const actionsClient = context.actions?.getActionsClient();
if (actionsClient == null) {
return response.badRequest({ body: 'Action client not found' });
}
try {
const params = pipe(
CasePushRequestParamsRt.decode(request.params),
fold(throwErrors(Boom.badRequest), identity)
);
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const { username, full_name, email } = await caseService.getUser({ request, response });
const pushedDate = new Date().toISOString();
const [myCase, myCaseConfigure, totalCommentsFindByCases, connectors] = await Promise.all([
caseService.getCase({
client,
caseId: request.params.case_id,
}),
caseConfigureService.find({ client }),
caseService.getAllCaseComments({
client,
caseId,
options: {
fields: [],
page: 1,
perPage: 1,
},
}),
actionsClient.getAll(),
]);
if (myCase.attributes.status === CaseStatuses.closed) {
throw Boom.conflict(
`This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.`
);
}
const comments = await caseService.getAllCaseComments({
client,
caseId,
options: {
fields: [],
page: 1,
perPage: totalCommentsFindByCases.total,
},
});
const externalService = {
pushed_at: pushedDate,
pushed_by: { username, full_name, email },
...query,
};
const updateConnector = myCase.attributes.connector;
if (
isEmpty(updateConnector) ||
(updateConnector != null && updateConnector.id === 'none') ||
!connectors.some((connector) => connector.id === updateConnector.id)
) {
throw Boom.notFound('Connector not found or set to none');
}
const [updatedCase, updatedComments] = await Promise.all([
caseService.patchCase({
client,
caseId,
updatedAttributes: {
...(myCaseConfigure.total > 0 &&
myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing'
? {
status: CaseStatuses.closed,
closed_at: pushedDate,
closed_by: { email, full_name, username },
}
: {}),
external_service: externalService,
updated_at: pushedDate,
updated_by: { username, full_name, email },
},
version: myCase.version,
}),
caseService.patchComments({
client,
comments: comments.saved_objects
.filter((comment) => comment.attributes.pushed_at == null)
.map((comment) => ({
commentId: comment.id,
updatedAttributes: {
pushed_at: pushedDate,
pushed_by: { username, full_name, email },
},
version: comment.version,
})),
}),
userActionService.postUserActions({
client,
actions: [
...(myCaseConfigure.total > 0 &&
myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing'
? [
buildCaseUserActionItem({
action: 'update',
actionAt: pushedDate,
actionBy: { username, full_name, email },
caseId,
fields: ['status'],
newValue: CaseStatuses.closed,
oldValue: myCase.attributes.status,
}),
]
: []),
buildCaseUserActionItem({
action: 'push-to-service',
actionAt: pushedDate,
actionBy: { username, full_name, email },
caseId,
fields: ['pushed'],
newValue: JSON.stringify(externalService),
}),
],
}),
]);
return response.ok({
body: CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: {
...myCase,
...updatedCase,
attributes: { ...myCase.attributes, ...updatedCase?.attributes },
references: myCase.references,
},
comments: comments.saved_objects.map((origComment) => {
const updatedComment = updatedComments.saved_objects.find(
(c) => c.id === origComment.id
);
return {
...origComment,
...updatedComment,
attributes: {
...origComment.attributes,
...updatedComment?.attributes,
...getCommentContextFromAttributes(origComment.attributes),
},
version: updatedComment?.version ?? origComment.version,
references: origComment?.references ?? [],
};
}),
})
),
body: await caseClient.push({
caseClient,
actionsClient,
caseId: params.case_id,
connectorId: params.connector_id,
}),
});
} catch (error) {
return response.customError(wrapError(error));

View file

@ -36,24 +36,24 @@ describe('GET status', () => {
method: 'get',
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, {
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, {
...findArgs,
filter: 'cases.attributes.status: open',
});
expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, {
expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, {
...findArgs,
filter: 'cases.attributes.status: in-progress',
});
expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, {
expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, {
...findArgs,
filter: 'cases.attributes.status: closed',
});
@ -71,13 +71,13 @@ describe('GET status', () => {
method: 'get',
});
const theContext = await createRouteContext(
const { context } = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: [{ ...mockCases[0], id: 'throw-error-find' }],
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const response = await routeHandler(context, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
});
});

View file

@ -7,13 +7,11 @@
import { schema } from '@kbn/config-schema';
import { CaseUserActionsResponseRt } from '../../../../../common/api';
import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types';
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { CASE_USER_ACTIONS_URL } from '../../../../../common/constants';
export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) {
export function initGetAllUserActionsApi({ router }: RouteDeps) {
router.get(
{
path: CASE_USER_ACTIONS_URL,
@ -24,22 +22,16 @@ export function initGetAllUserActionsApi({ userActionService, router }: RouteDep
},
},
async (context, request, response) => {
if (!context.case) {
return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' });
}
const caseClient = context.case.getCaseClient();
const caseId = request.params.case_id;
try {
const client = context.core.savedObjects.client;
const userActions = await userActionService.getUserActions({
client,
caseId: request.params.case_id,
});
return response.ok({
body: CaseUserActionsResponseRt.encode(
userActions.saved_objects.map((ua) => ({
...ua.attributes,
action_id: ua.id,
case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
comment_id:
ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
}))
),
body: await caseClient.getUserActions({ caseId }),
});
} catch (error) {
return response.customError(wrapError(error));

View file

@ -10,7 +10,7 @@ import { initFindCasesApi } from '././cases/find_cases';
import { initGetCaseApi } from './cases/get_case';
import { initPatchCasesApi } from './cases/patch_cases';
import { initPostCaseApi } from './cases/post_case';
import { initPushCaseUserActionApi } from './cases/push_case';
import { initPushCaseApi } from './cases/push_case';
import { initGetReportersApi } from './cases/reporters/get_reporters';
import { initGetCasesStatusApi } from './cases/status/get_status';
import { initGetTagsApi } from './cases/tags/get_tags';
@ -28,7 +28,6 @@ import { initCaseConfigureGetActionConnector } from './cases/configure/get_conne
import { initGetCaseConfigure } from './cases/configure/get_configure';
import { initPatchCaseConfigure } from './cases/configure/patch_configure';
import { initPostCaseConfigure } from './cases/configure/post_configure';
import { initPostPushToService } from './cases/configure/post_push_to_service';
import { RouteDeps } from './types';
@ -39,7 +38,7 @@ export function initCaseApi(deps: RouteDeps) {
initGetCaseApi(deps);
initPatchCasesApi(deps);
initPostCaseApi(deps);
initPushCaseUserActionApi(deps);
initPushCaseApi(deps);
initGetAllUserActionsApi(deps);
// Comments
initDeleteCommentApi(deps);
@ -54,7 +53,6 @@ export function initCaseApi(deps: RouteDeps) {
initGetCaseConfigure(deps);
initPatchCaseConfigure(deps);
initPostCaseConfigure(deps);
initPostPushToService(deps);
// Reporters
initGetReportersApi(deps);
// Status

View file

@ -191,11 +191,11 @@ export const sortToSnake = (sortField: string): SortFieldCase => {
export const escapeHatch = schema.object({}, { unknowns: 'allow' });
const isUserContext = (context: CommentRequest): context is CommentRequestUserType => {
export const isUserContext = (context: CommentRequest): context is CommentRequestUserType => {
return context.type === CommentType.user;
};
const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => {
export const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => {
return context.type === CommentType.alert;
};
@ -206,17 +206,3 @@ export const decodeComment = (comment: CommentRequest) => {
pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity));
}
};
export const getCommentContextFromAttributes = (
attributes: CommentAttributes
): CommentRequestUserType | CommentRequestAlertType =>
isUserContext(attributes)
? {
type: CommentType.user,
comment: attributes.comment,
}
: {
type: CommentType.alert,
alertId: attributes.alertId,
index: attributes.index,
};

View file

@ -19,6 +19,24 @@ interface UpdateAlertsStatusArgs {
index: string;
}
interface GetAlertsArgs {
request: KibanaRequest;
ids: string[];
index: string;
}
interface Alert {
_id: string;
_index: string;
_source: Record<string, unknown>;
}
interface AlertsResponse {
hits: {
hits: Alert[];
};
}
export class AlertService {
private isInitialized = false;
private esClient?: IClusterClient;
@ -55,4 +73,30 @@ export class AlertService {
return result;
}
public async getAlerts({ request, ids, index }: GetAlertsArgs): Promise<AlertsResponse> {
if (!this.isInitialized) {
throw new Error('AlertService not initialized');
}
// The above check makes sure that esClient is defined.
const result = await this.esClient!.asScoped(request).asCurrentUser.search<AlertsResponse>({
index,
body: {
query: {
bool: {
filter: {
bool: {
should: ids.map((_id) => ({ match: { _id } })),
minimum_should_match: 1,
},
},
},
},
},
ignore_unavailable: true,
});
return result.body;
}
}

View file

@ -59,4 +59,5 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({
export const createAlertServiceMock = (): AlertServiceMock => ({
initialize: jest.fn(),
updateAlertsStatus: jest.fn(),
getAlerts: jest.fn(),
});

View file

@ -31,6 +31,13 @@ describe('Cases connector incident fields', () => {
beforeEach(() => {
cleanKibana();
cy.intercept('GET', '/api/cases/configure/connectors/_find', mockConnectorsResponse);
cy.intercept('POST', `/api/actions/action/${connectorIds.sn}/_execute`, (req) => {
const response =
req.body.params.subAction === 'getChoices'
? executeResponses.servicenow.choices
: { status: 'ok', data: [] };
req.reply(response);
});
cy.intercept('POST', `/api/actions/action/${connectorIds.jira}/_execute`, (req) => {
const response =
req.body.params.subAction === 'issueTypes'

View file

@ -113,6 +113,77 @@ export const mockConnectorsResponse = [
},
];
export const executeResponses = {
servicenow: {
choices: {
status: 'ok',
data: [
{
dependent_value: '',
label: 'Priviledge Escalation',
value: 'Priviledge Escalation',
element: 'category',
},
{
dependent_value: '',
label: 'Criminal activity/investigation',
value: 'Criminal activity/investigation',
element: 'category',
},
{
dependent_value: '',
label: 'Denial of Service',
value: 'Denial of Service',
element: 'category',
},
{
dependent_value: 'Denial of Service',
label: 'Inbound or outbound',
value: '12',
element: 'subcategory',
},
{
dependent_value: 'Denial of Service',
label: 'Single or distributed (DoS or DDoS)',
value: '26',
element: 'subcategory',
},
{
dependent_value: 'Denial of Service',
label: 'Inbound DDos',
value: 'inbound_ddos',
element: 'subcategory',
},
...['severity', 'urgency', 'impact', 'priority']
.map((element) => [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element,
},
{
dependent_value: '',
label: '2 - High',
value: '2',
element,
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
element,
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
element,
},
])
.flat(),
],
},
},
jira: {
issueTypes: {
status: 'ok',

View file

@ -30,9 +30,9 @@ export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME =
export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]';
export const CONNECTOR_CARD_DETAILS = '[data-test-subj="settings-connector-card"]';
export const CONNECTOR_CARD_DETAILS = '[data-test-subj="connector-card"]';
export const CONNECTOR_TITLE = '[data-test-subj="settings-connector-card"] span.euiTitle';
export const CONNECTOR_TITLE = '[data-test-subj="connector-card"] span.euiTitle';
export const DELETE_CASE_BTN = '[data-test-subj="property-actions-trash"]';

View file

@ -7,7 +7,7 @@
import { connectorIds } from '../objects/case';
export const CONNECTOR_RESILIENT = `[data-test-subj="connector-settings-resilient"]`;
export const CONNECTOR_RESILIENT = `[data-test-subj="connector-fields-resilient"]`;
export const CONNECTOR_SELECTOR = '[data-test-subj="dropdown-connectors"]';

View file

@ -107,7 +107,7 @@ describe('CaseView ', () => {
const fetchCaseUserActions = jest.fn();
const fetchCase = jest.fn();
const updateCase = jest.fn();
const postPushToService = jest.fn();
const pushCaseToExternalService = jest.fn();
const data = caseProps.caseData;
const defaultGetCase = {
@ -144,7 +144,10 @@ describe('CaseView ', () => {
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService }));
usePostPushToServiceMock.mockImplementation(() => ({
isLoading: false,
pushCaseToExternalService,
}));
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useQueryAlertsMock.mockImplementation(() => ({
loading: false,
@ -378,7 +381,7 @@ describe('CaseView ', () => {
wrapper.update();
expect(postPushToService).toHaveBeenCalled();
expect(pushCaseToExternalService).toHaveBeenCalled();
});
});
@ -508,7 +511,7 @@ describe('CaseView ', () => {
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}}
@ -556,7 +559,7 @@ describe('CaseView ', () => {
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}}

View file

@ -297,7 +297,6 @@ export const CaseComponent = React.memo<CaseProps>(
updateCase: handleUpdateCase,
userCanCrud,
isValidConnector: isLoadingConnectors ? true : isValidConnector,
alerts,
});
const onSubmitConnector = useCallback(
@ -397,7 +396,6 @@ export const CaseComponent = React.memo<CaseProps>(
);
}
}, [dispatch]);
return (
<>
<HeaderWrapper>

View file

@ -72,7 +72,7 @@ describe('Connectors', () => {
const newWrapper = mount(
<Connectors
{...props}
selectedConnector={{ id: 'servicenow-1', type: ConnectorTypes.servicenow }}
selectedConnector={{ id: 'servicenow-1', type: ConnectorTypes.serviceNowITSM }}
/>,
{
wrappingComponent: TestProviders,
@ -99,7 +99,7 @@ describe('Connectors', () => {
const newWrapper = mount(
<Connectors
{...props}
selectedConnector={{ id: 'servicenow-1', type: ConnectorTypes.servicenow }}
selectedConnector={{ id: 'servicenow-1', type: ConnectorTypes.serviceNowITSM }}
/>,
{
wrappingComponent: TestProviders,

View file

@ -186,14 +186,14 @@ describe('ConfigureCases', () => {
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
currentConfiguration: {
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
closureType: 'close-by-user',
@ -271,7 +271,7 @@ describe('ConfigureCases', () => {
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
closureType: 'close-by-user',
@ -331,7 +331,7 @@ describe('ConfigureCases', () => {
connector: {
id: 'servicenow-1',
name: 'SN',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
persistLoading: true,
@ -450,7 +450,7 @@ describe('ConfigureCases', () => {
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}))
@ -493,7 +493,7 @@ describe('closure options', () => {
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
currentConfiguration: {
@ -522,7 +522,7 @@ describe('closure options', () => {
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
closureType: 'close-by-pushing',
@ -546,7 +546,7 @@ describe('user interactions', () => {
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.servicenow,
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
closureType: 'close-by-user',

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { isEmpty } from 'lodash/fp';
import { EuiFormRow } from '@elastic/eui';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports';
import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown';
import { ActionConnector } from '../../../../../case/common/api/cases';
import { ActionConnector } from '../../../../../case/common/api';
interface ConnectorSelectorProps {
connectors: ActionConnector[];
@ -21,6 +21,7 @@ interface ConnectorSelectorProps {
idAria: string;
isEdit: boolean;
isLoading: boolean;
handleChange?: (newValue: string) => void;
}
export const ConnectorSelector = ({
connectors,
@ -30,8 +31,19 @@ export const ConnectorSelector = ({
idAria,
isEdit = true,
isLoading = false,
handleChange,
}: ConnectorSelectorProps) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const onChange = useCallback(
(val: string) => {
if (handleChange) {
handleChange(val);
}
field.setValue(val);
},
[handleChange, field]
);
return isEdit ? (
<EuiFormRow
data-test-subj={dataTestSubj}
@ -47,7 +59,7 @@ export const ConnectorSelector = ({
connectors={connectors}
disabled={disabled}
isLoading={isLoading}
onChange={field.setValue}
onChange={onChange}
selectedConnector={isEmpty(field.value) ? 'none' : field.value}
/>
</EuiFormRow>

View file

@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react';
import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui';
import styled from 'styled-components';
import { connectorsConfiguration } from '../connectors';
import { connectorsConfiguration } from '.';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
interface ConnectorCardProps {
@ -51,10 +51,10 @@ const ConnectorCardDisplay: React.FC<ConnectorCardProps> = ({
);
return (
<>
{isLoading && <EuiLoadingSpinner data-test-subj="settings-connector-card-loading" />}
{isLoading && <EuiLoadingSpinner data-test-subj="connector-card-loading" />}
{!isLoading && (
<EuiCard
data-test-subj={`settings-connector-card`}
data-test-subj={`connector-card`}
description={description}
display="plain"
icon={icon}

View file

@ -37,6 +37,6 @@ export function getActionType(): ActionTypeModel {
validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }),
validateParams,
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./fields')),
actionParamsFields: lazy(() => import('./alert_fields')),
};
}

View file

@ -5,17 +5,35 @@
* 2.0.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import {
ServiceNowITSMConnectorConfiguration,
JiraConnectorConfiguration,
ResilientConnectorConfiguration,
getResilientActionType,
getServiceNowITSMActionType,
getServiceNowSIRActionType,
getJiraActionType,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../triggers_actions_ui/public/common';
import { ConnectorConfiguration } from './types';
const resilient = getResilientActionType();
const serviceNowITSM = getServiceNowITSMActionType();
const serviceNowSIR = getServiceNowSIRActionType();
const jira = getJiraActionType();
export const connectorsConfiguration: Record<string, ConnectorConfiguration> = {
'.servicenow': ServiceNowITSMConnectorConfiguration as ConnectorConfiguration,
'.jira': JiraConnectorConfiguration as ConnectorConfiguration,
'.resilient': ResilientConnectorConfiguration as ConnectorConfiguration,
'.servicenow': {
name: serviceNowITSM.actionTypeTitle ?? '',
logo: serviceNowITSM.iconClass,
},
'.servicenow-sir': {
name: serviceNowSIR.actionTypeTitle ?? '',
logo: serviceNowSIR.iconClass,
},
'.jira': {
name: jira.actionTypeTitle ?? '',
logo: jira.iconClass,
},
'.resilient': {
name: resilient.actionTypeTitle ?? '',
logo: resilient.iconClass,
},
};

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { CaseConnector, CaseConnectorsRegistry } from './types';
/* eslint-disable @typescript-eslint/no-explicit-any */
export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => {
const connectors: Map<string, CaseConnector<any>> = new Map();
const registry: CaseConnectorsRegistry = {
has: (id: string) => connectors.has(id),
register: <UIProps>(connector: CaseConnector<UIProps>) => {
if (connectors.has(connector.id)) {
throw new Error(
i18n.translate(
'xpack.securitySolution.caseConnectorsRegistry.register.duplicateCaseConnectorErrorMessage',
{
defaultMessage: 'Object type "{id}" is already registered.',
values: {
id: connector.id,
},
}
)
);
}
connectors.set(connector.id, connector);
},
get: <UIProps>(id: string): CaseConnector<UIProps> => {
if (!connectors.has(id)) {
throw new Error(
i18n.translate(
'xpack.securitySolution.caseConnectorsRegistry.get.missingCaseConnectorErrorMessage',
{
defaultMessage: 'Object type "{id}" is not registered.',
values: {
id,
},
}
)
);
}
return connectors.get(id)!;
},
list: () => {
return Array.from(connectors).map(([id, connector]) => connector);
},
};
return registry;
};

View file

@ -8,24 +8,22 @@
import React, { memo, Suspense } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { CaseSettingsConnector, SettingFieldsProps } from './types';
import { getCaseSettings } from '.';
import { CaseActionConnector, ConnectorFieldsProps } from './types';
import { getCaseConnectors } from '.';
import { ConnectorTypeFields } from '../../../../../case/common/api/connectors';
interface Props extends Omit<SettingFieldsProps<ConnectorTypeFields['fields']>, 'connector'> {
connector: CaseSettingsConnector | null;
interface Props extends Omit<ConnectorFieldsProps<ConnectorTypeFields['fields']>, 'connector'> {
connector: CaseActionConnector | null;
}
const SettingFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChange, fields }) => {
const { caseSettingsRegistry } = getCaseSettings();
const ConnectorFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChange, fields }) => {
const { caseConnectorsRegistry } = getCaseConnectors();
if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') {
return null;
}
const { caseSettingFieldsComponent: FieldsComponent } = caseSettingsRegistry.get(
connector.actionTypeId
);
const { fieldsComponent: FieldsComponent } = caseConnectorsRegistry.get(connector.actionTypeId);
return (
<>
@ -39,7 +37,7 @@ const SettingFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChan
</EuiFlexGroup>
}
>
<div data-test-subj={'connector-settings'}>
<div data-test-subj={'connector-fields'}>
<FieldsComponent
isEdit={isEdit}
fields={fields}
@ -53,4 +51,4 @@ const SettingFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChan
);
};
export const SettingFieldsForm = memo(SettingFieldsFormComponent);
export const ConnectorFieldsForm = memo(ConnectorFieldsFormComponent);

View file

@ -5,7 +5,53 @@
* 2.0.
*/
import { CaseConnectorsRegistry } from './types';
import { createCaseConnectorsRegistry } from './connectors_registry';
import { getCaseConnector as getJiraCaseConnector } from './jira';
import { getCaseConnector as getResilientCaseConnector } from './resilient';
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
import {
JiraFieldsType,
ServiceNowITSMFieldsType,
ServiceNowSIRFieldsType,
ResilientFieldsType,
} from '../../../../../case/common/api/connectors';
export { getActionType as getCaseConnectorUI } from './case';
export * from './config';
export * from './types';
interface GetCaseConnectorsReturn {
caseConnectorsRegistry: CaseConnectorsRegistry;
}
class CaseConnectors {
private caseConnectorsRegistry: CaseConnectorsRegistry;
constructor() {
this.caseConnectorsRegistry = createCaseConnectorsRegistry();
this.init();
}
private init() {
this.caseConnectorsRegistry.register<JiraFieldsType>(getJiraCaseConnector());
this.caseConnectorsRegistry.register<ResilientFieldsType>(getResilientCaseConnector());
this.caseConnectorsRegistry.register<ServiceNowITSMFieldsType>(
getServiceNowITSMCaseConnector()
);
this.caseConnectorsRegistry.register<ServiceNowSIRFieldsType>(getServiceNowSIRCaseConnector());
}
registry(): CaseConnectorsRegistry {
return this.caseConnectorsRegistry;
}
}
const caseConnectors = new CaseConnectors();
export const getCaseConnectors = (): GetCaseConnectorsReturn => {
return {
caseConnectorsRegistry: caseConnectors.registry(),
};
};

View file

@ -12,7 +12,7 @@ import { omit } from 'lodash/fp';
import { connector, issues } from '../mock';
import { useGetIssueTypes } from './use_get_issue_types';
import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import Fields from './fields';
import Fields from './case_fields';
import { waitFor } from '@testing-library/dom';
import { useGetSingleIssue } from './use_get_single_issue';
import { useGetIssues } from './use_get_issues';

View file

@ -5,25 +5,26 @@
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useEffect, useRef } from 'react';
import { map } from 'lodash/fp';
import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import * as i18n from './translations';
import { ConnectorTypes, JiraFieldsType } from '../../../../../../case/common/api/connectors';
import { useKibana } from '../../../../common/lib/kibana';
import { SettingFieldsProps } from '../types';
import { ConnectorFieldsProps } from '../types';
import { useGetIssueTypes } from './use_get_issue_types';
import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import { SearchIssues } from './search_issues';
import { ConnectorCard } from '../card';
const JiraSettingFieldsComponent: React.FunctionComponent<SettingFieldsProps<JiraFieldsType>> = ({
const JiraFieldsComponent: React.FunctionComponent<ConnectorFieldsProps<JiraFieldsType>> = ({
connector,
fields,
isEdit = true,
onChange,
}) => {
const init = useRef(true);
const { issueType = null, priority = null, parent = null } = fields ?? {};
const { http, notifications } = useKibana().services;
@ -138,8 +139,16 @@ const JiraSettingFieldsComponent: React.FunctionComponent<SettingFieldsProps<Jir
[currentIssueType, fields, onChange, parent, priority]
);
// Set field at initialization
useEffect(() => {
if (init.current) {
init.current = false;
onChange({ issueType, priority, parent });
}
}, [issueType, onChange, parent, priority]);
return isEdit ? (
<div data-test-subj={'connector-settings-jira'}>
<div data-test-subj={'connector-fields-jira'}>
<EuiFormRow fullWidth label={i18n.ISSUE_TYPE}>
<EuiSelect
data-test-subj="issueTypeSelect"
@ -202,4 +211,4 @@ const JiraSettingFieldsComponent: React.FunctionComponent<SettingFieldsProps<Jir
};
// eslint-disable-next-line import/no-default-export
export { JiraSettingFieldsComponent as default };
export { JiraFieldsComponent as default };

View file

@ -7,16 +7,16 @@
import { lazy } from 'react';
import { CaseSetting } from '../types';
import { CaseConnector } from '../types';
import { JiraFieldsType } from '../../../../../../case/common/api/connectors';
import * as i18n from './translations';
export * from './types';
export const getCaseSetting = (): CaseSetting<JiraFieldsType> => {
export const getCaseConnector = (): CaseConnector<JiraFieldsType> => {
return {
id: '.jira',
caseSettingFieldsComponent: lazy(() => import('./fields')),
fieldsComponent: lazy(() => import('./case_fields')),
};
};

Some files were not shown because too many files have changed in this diff Show more