[Security Solution] [Cases] Move field mappings from actions to cases (#84587)

This commit is contained in:
Steph Milovic 2020-12-15 07:06:11 -07:00 committed by GitHub
parent d4a631cf8e
commit 335cd1f6fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
173 changed files with 3846 additions and 6241 deletions

View file

@ -42,6 +42,7 @@ const allowedList: CircularDepList = new Set([
'src/plugins/vis_default_editor -> src/plugins/visualize',
'src/plugins/visualizations -> src/plugins/visualize',
'x-pack/plugins/actions -> x-pack/plugins/case',
'x-pack/plugins/case -> x-pack/plugins/security_solution',
'x-pack/plugins/apm -> x-pack/plugins/infra',
'x-pack/plugins/lists -> x-pack/plugins/security_solution',
'x-pack/plugins/security -> x-pack/plugins/spaces',

View file

@ -553,7 +553,6 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a
| Property | Description | Type |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
| apiUrl | ServiceNow instance URL. | string |
| incidentConfiguration | Optional property and specific to **Cases only**. If defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the ServiceNow field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'short_description', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object _(optional)_ |
### `secrets`
@ -600,7 +599,6 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla
| Property | Description | Type |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| apiUrl | Jira instance URL. | string |
| incidentConfiguration | Optional property and specific to **Cases only**. if defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object _(optional)_ |
### `secrets`
@ -653,7 +651,6 @@ ID: `.resilient`
| Property | Description | Type |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| apiUrl | IBM Resilient instance URL. | string |
| incidentConfiguration | Optional property and specific to **Cases only**. If defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object |
### `secrets`

View file

@ -1,7 +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;
* you may not use this file except in compliance with the Elastic License.
*/
export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description'];

View file

@ -1,43 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
export const MappingActionType = schema.oneOf([
schema.literal('nothing'),
schema.literal('overwrite'),
schema.literal('append'),
]);
export const MapRecordSchema = schema.object({
source: schema.string(),
target: schema.string(),
actionType: MappingActionType,
});
export const IncidentConfigurationSchema = schema.object({
mapping: schema.arrayOf(MapRecordSchema),
});
export const UserSchema = schema.object({
fullName: schema.nullable(schema.string()),
username: schema.nullable(schema.string()),
});
export const EntityInformation = {
createdAt: schema.nullable(schema.string()),
createdBy: schema.nullable(UserSchema),
updatedAt: schema.nullable(schema.string()),
updatedBy: schema.nullable(UserSchema),
};
export const EntityInformationSchema = schema.object(EntityInformation);
export const CommentSchema = schema.object({
commentId: schema.string(),
comment: schema.string(),
...EntityInformation,
});

View file

@ -1,131 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { transformers } from './transformers';
const { informationCreated, informationUpdated, informationAdded, append } = transformers;
describe('informationCreated', () => {
test('transforms correctly', () => {
const res = informationCreated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
});
expect(res).toEqual({ value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)' });
});
test('transforms correctly without optional fields', () => {
const res = informationCreated({
value: 'a value',
});
expect(res).toEqual({ value: 'a value (created at by )' });
});
test('returns correctly rest fields', () => {
const res = informationCreated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)',
previousValue: 'previous value',
});
});
});
describe('informationUpdated', () => {
test('transforms correctly', () => {
const res = informationUpdated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
});
expect(res).toEqual({ value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)' });
});
test('transforms correctly without optional fields', () => {
const res = informationUpdated({
value: 'a value',
});
expect(res).toEqual({ value: 'a value (updated at by )' });
});
test('returns correctly rest fields', () => {
const res = informationUpdated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)',
previousValue: 'previous value',
});
});
});
describe('informationAdded', () => {
test('transforms correctly', () => {
const res = informationAdded({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
});
expect(res).toEqual({ value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)' });
});
test('transforms correctly without optional fields', () => {
const res = informationAdded({
value: 'a value',
});
expect(res).toEqual({ value: 'a value (added at by )' });
});
test('returns correctly rest fields', () => {
const res = informationAdded({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)',
previousValue: 'previous value',
});
});
});
describe('append', () => {
test('transforms correctly', () => {
const res = append({
value: 'a value',
previousValue: 'previous value',
});
expect(res).toEqual({ value: 'previous value \r\na value' });
});
test('transforms correctly without optional fields', () => {
const res = append({
value: 'a value',
});
expect(res).toEqual({ value: 'a value' });
});
test('returns correctly rest fields', () => {
const res = append({
value: 'a value',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'previous value \r\na value',
user: 'elastic',
});
});
});

View file

@ -1,29 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { TransformerArgs } from './types';
import * as i18n from './translations';
export type Transformer = (args: TransformerArgs) => TransformerArgs;
export const transformers: Record<string, Transformer> = {
informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${i18n.FIELD_INFORMATION('create', date, user)}`,
...rest,
}),
informationUpdated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${i18n.FIELD_INFORMATION('update', date, user)}`,
...rest,
}),
informationAdded: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${i18n.FIELD_INFORMATION('add', date, user)}`,
...rest,
}),
append: ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({
value: previousValue ? `${previousValue} \r\n${value}` : `${value}`,
...rest,
}),
};

View file

@ -1,55 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const API_URL_REQUIRED = i18n.translate('xpack.actions.builtin.case.connectorApiNullError', {
defaultMessage: 'connector [apiUrl] is required',
});
export const FIELD_INFORMATION = (
mode: string,
date: string | undefined,
user: string | undefined
) => {
switch (mode) {
case 'create':
return i18n.translate('xpack.actions.builtin.case.common.externalIncidentCreated', {
values: { date, user },
defaultMessage: '(created at {date} by {user})',
});
case 'update':
return i18n.translate('xpack.actions.builtin.case.common.externalIncidentUpdated', {
values: { date, user },
defaultMessage: '(updated at {date} by {user})',
});
case 'add':
return i18n.translate('xpack.actions.builtin.case.common.externalIncidentAdded', {
values: { date, user },
defaultMessage: '(added at {date} by {user})',
});
default:
return i18n.translate('xpack.actions.builtin.case.common.externalIncidentDefault', {
values: { date, user },
defaultMessage: '(created at {date} by {user})',
});
}
};
export const MAPPING_EMPTY = i18n.translate(
'xpack.actions.builtin.case.configuration.emptyMapping',
{
defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty',
}
);
export const WHITE_LISTED_ERROR = (message: string) =>
i18n.translate('xpack.actions.builtin.case.configuration.apiWhitelistError', {
defaultMessage: 'error configuring connector action: {message}',
values: {
message,
},
});

View file

@ -1,54 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { TypeOf } from '@kbn/config-schema';
import {
IncidentConfigurationSchema,
MapRecordSchema,
CommentSchema,
EntityInformationSchema,
} from './schema';
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export type MapRecord = TypeOf<typeof MapRecordSchema>;
export type Comment = TypeOf<typeof CommentSchema>;
export type EntityInformation = TypeOf<typeof EntityInformationSchema>;
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export interface PipedField {
key: string;
value: string;
actionType: string;
pipes: string[];
}
export interface TransformFieldsArgs<P, S> {
params: P;
fields: PipedField[];
currentIncident?: S;
}
export interface TransformerArgs {
value: string;
date?: string;
user?: string;
previousValue?: string;
}
export interface AnyParams {
[index: string]: string | number | object | undefined | null;
}
export interface PrepareFieldsForTransformArgs {
externalCase: Record<string, string>;
mapping: Map<string, MapRecord>;
defaultPipes?: string[];
}

View file

@ -1,494 +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;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
normalizeMapping,
buildMap,
mapParams,
prepareFieldsForTransformation,
transformFields,
transformComments,
} from './utils';
import { SUPPORTED_SOURCE_FIELDS } from './constants';
import { Comment, MapRecord } from './types';
interface Entity {
createdAt: string | null;
createdBy: { fullName: string; username: string } | null;
updatedAt: string | null;
updatedBy: { fullName: string; username: string } | null;
}
interface PushToServiceApiParams extends Entity {
savedObjectId: string;
title: string;
description: string | null;
externalId: string | null;
externalObject: Record<string, any>;
comments: Comment[];
}
const mapping: MapRecord[] = [
{ source: 'title', target: 'short_description', actionType: 'overwrite' },
{ source: 'description', target: 'description', actionType: 'append' },
{ source: 'comments', target: 'comments', actionType: 'append' },
];
const finalMapping: Map<string, any> = new Map();
finalMapping.set('title', {
target: 'short_description',
actionType: 'overwrite',
});
finalMapping.set('description', {
target: 'description',
actionType: 'append',
});
finalMapping.set('comments', {
target: 'comments',
actionType: 'append',
});
finalMapping.set('short_description', {
target: 'title',
actionType: 'overwrite',
});
const maliciousMapping: MapRecord[] = [
{ source: '__proto__', target: 'short_description', actionType: 'nothing' },
{ source: 'description', target: '__proto__', actionType: 'nothing' },
{ source: 'comments', target: 'comments', actionType: 'nothing' },
{ source: 'unsupportedSource', target: 'comments', actionType: 'nothing' },
];
const fullParams: PushToServiceApiParams = {
savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
title: 'a title',
description: 'a description',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
externalId: null,
externalObject: {
short_description: 'a title',
description: 'a description',
},
comments: [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'second comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
],
};
describe('normalizeMapping', () => {
test('remove malicious fields', () => {
const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping);
expect(
sanitizedMapping.every((m) => m.source !== '__proto__' && m.target !== '__proto__')
).toBe(true);
});
test('remove unsuppported source fields', () => {
const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping);
expect(normalizedMapping).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
source: 'unsupportedSource',
target: 'comments',
actionType: 'nothing',
}),
])
);
});
});
describe('buildMap', () => {
test('builds sanitized Map', () => {
const finalMap = buildMap(maliciousMapping);
expect(finalMap.get('__proto__')).not.toBeDefined();
});
test('builds Map correct', () => {
const final = buildMap(mapping);
expect(final).toEqual(finalMapping);
});
});
describe('mapParams', () => {
test('maps params correctly', () => {
const params = {
savedObjectId: '123',
incidentId: '456',
title: 'Incident title',
description: 'Incident description',
};
const fields = mapParams(params, finalMapping);
expect(fields).toEqual({
short_description: 'Incident title',
description: 'Incident description',
});
});
test('do not add fields not in mapping', () => {
const params = {
savedObjectId: '123',
incidentId: '456',
title: 'Incident title',
description: 'Incident description',
};
const fields = mapParams(params, finalMapping);
const { title, description, ...unexpectedFields } = params;
expect(fields).not.toEqual(expect.objectContaining(unexpectedFields));
});
});
describe('prepareFieldsForTransformation', () => {
test('prepare fields with defaults', () => {
const res = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
});
expect(res).toEqual([
{
key: 'short_description',
value: 'a title',
actionType: 'overwrite',
pipes: ['informationCreated'],
},
{
key: 'description',
value: 'a description',
actionType: 'append',
pipes: ['informationCreated', 'append'],
},
]);
});
test('prepare fields with default pipes', () => {
const res = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['myTestPipe'],
});
expect(res).toEqual([
{
key: 'short_description',
value: 'a title',
actionType: 'overwrite',
pipes: ['myTestPipe'],
},
{
key: 'description',
value: 'a description',
actionType: 'append',
pipes: ['myTestPipe', 'append'],
},
]);
});
});
describe('transformFields', () => {
test('transform fields for creation correctly', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
});
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: fullParams,
fields,
});
expect(res).toEqual({
short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
});
});
test('transform fields for update correctly', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: {
...fullParams,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
username: 'anotherUser',
fullName: 'Another User',
},
},
fields,
currentIncident: {
short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
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:
'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)',
});
});
test('add newline character to description', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: fullParams,
fields,
currentIncident: {
short_description: 'first title',
description: 'first description',
},
});
expect(res.description?.includes('\r\n')).toBe(true);
});
test('append username if fullname is undefined when create', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
});
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: {
...fullParams,
createdBy: { fullName: '', username: 'elastic' },
},
fields,
});
expect(res).toEqual({
short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)',
description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)',
});
});
test('append username if fullname is undefined when update', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: {
...fullParams,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: { username: 'anotherUser', fullName: '' },
},
fields,
});
expect(res).toEqual({
short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)',
description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)',
});
});
});
describe('transformComments', () => {
test('transform creation comments', () => {
const comments: Comment[] = [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
];
const res = transformComments(comments, ['informationCreated']);
expect(res).toEqual([
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment (created at 2020-03-13T08:34:53.450Z by Elastic User)',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
]);
});
test('transform update comments', () => {
const comments: Comment[] = [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
fullName: 'Another User',
username: 'anotherUser',
},
},
];
const res = transformComments(comments, ['informationUpdated']);
expect(res).toEqual([
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment (updated at 2020-03-15T08:34:53.450Z by Another User)',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
fullName: 'Another User',
username: 'anotherUser',
},
},
]);
});
test('transform added comments', () => {
const comments: Comment[] = [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
]);
});
test('transform comments without fullname', () => {
const comments: Comment[] = [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: '', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment (added at 2020-03-13T08:34:53.450Z by elastic)',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: '', username: 'elastic' },
updatedAt: null,
updatedBy: null,
},
]);
});
test('adds update user correctly', () => {
const comments: Comment[] = [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic', username: 'elastic' },
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic2', username: 'elastic' },
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment (added at 2020-04-13T08:34:53.450Z by Elastic2)',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic', username: 'elastic' },
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic2', username: 'elastic' },
},
]);
});
test('adds update user with empty fullname correctly', () => {
const comments: Comment[] = [
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic', username: 'elastic' },
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: '', username: 'elastic2' },
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631',
comment: 'first comment (added at 2020-04-13T08:34:53.450Z by elastic2)',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic', username: 'elastic' },
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: '', username: 'elastic2' },
},
]);
});
});

View file

@ -1,111 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { flow, get } from 'lodash';
import {
MapRecord,
TransformFieldsArgs,
Comment,
EntityInformation,
PipedField,
AnyParams,
PrepareFieldsForTransformArgs,
} from './types';
import { transformers } from './transformers';
import { SUPPORTED_SOURCE_FIELDS } from './constants';
export const normalizeMapping = (supportedFields: string[], mapping: MapRecord[]): MapRecord[] => {
// Prevent prototype pollution and remove unsupported fields
return mapping.filter(
(m) =>
m.source !== '__proto__' && m.target !== '__proto__' && supportedFields.includes(m.source)
);
};
export const buildMap = (mapping: MapRecord[]): Map<string, MapRecord> => {
return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => {
const { source, target, actionType } = field;
fieldsMap.set(source, { target, actionType });
fieldsMap.set(target, { target: source, actionType });
return fieldsMap;
}, new Map());
};
export const mapParams = <T extends {}>(params: T, mapping: Map<string, MapRecord>): AnyParams => {
return Object.keys(params).reduce((prev: AnyParams, curr: string): AnyParams => {
const field = mapping.get(curr);
if (field) {
prev[field.target] = get(params, curr);
}
return prev;
}, {});
};
export const prepareFieldsForTransformation = ({
externalCase,
mapping,
defaultPipes = ['informationCreated'],
}: PrepareFieldsForTransformArgs): PipedField[] => {
return Object.keys(externalCase)
.filter((p) => mapping.get(p)?.actionType != null && mapping.get(p)?.actionType !== 'nothing')
.map((p) => {
const actionType = mapping.get(p)?.actionType ?? 'nothing';
return {
key: p,
value: externalCase[p],
actionType,
pipes: actionType === 'append' ? [...defaultPipes, 'append'] : defaultPipes,
};
});
};
export const transformFields = <
P extends EntityInformation,
S extends Record<string, unknown>,
R extends {}
>({
params,
fields,
currentIncident,
}: TransformFieldsArgs<P, S>): R => {
return fields.reduce((prev, cur) => {
const transform = flow(...cur.pipes.map((p) => transformers[p]));
return {
...prev,
[cur.key]: transform({
value: cur.value,
date: params.updatedAt ?? params.createdAt,
user: getEntity(params),
previousValue: currentIncident ? currentIncident[cur.key] : '',
}).value,
};
}, {} as R);
};
export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => {
return comments.map((c) => ({
...c,
comment: flow(...pipes.map((p) => transformers[p]))({
value: c.comment,
date: c.updatedAt ?? c.createdAt,
user: getEntity(c),
}).value,
}));
};
export const getEntity = (entity: EntityInformation): string =>
(entity.updatedBy != null
? entity.updatedBy.fullName
? entity.updatedBy.fullName
: entity.updatedBy.username
: entity.createdBy != null
? entity.createdBy.fullName
? entity.createdBy.fullName
: entity.createdBy.username
: '') ?? '';

View file

@ -5,7 +5,7 @@
*/
import { Logger } from '../../../../../../src/core/server';
import { externalServiceMock, mapping, apiParams } from './mocks';
import { externalServiceMock, apiParams } from './mocks';
import { ExternalService } from './types';
import { api } from './api';
let mockedLogger: jest.Mocked<Logger>;
@ -22,7 +22,6 @@ describe('api', () => {
const params = { ...apiParams, externalId: null };
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
@ -49,7 +48,6 @@ describe('api', () => {
const params = { ...apiParams, externalId: null, comments: [] };
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
@ -63,8 +61,8 @@ describe('api', () => {
});
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
@ -72,16 +70,16 @@ describe('api', () => {
priority: 'High',
issueType: '10006',
parent: null,
description: 'Incident description (created at 2020-04-27T10:59:46.202Z by Elastic User)',
summary: 'Incident title (created at 2020-04-27T10:59:46.202Z by Elastic User)',
description: 'Incident description',
summary: 'Incident title',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
test('it calls createIncident correctly without mapping', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
@ -97,65 +95,35 @@ describe('api', () => {
});
test('it calls createComment correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
});
test('it calls createComment correctly without mapping', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
test('it calls createComment correctly without mapping', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
@ -164,16 +132,6 @@ describe('api', () => {
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
});
@ -183,7 +141,6 @@ describe('api', () => {
test('it updates an incident', async () => {
const res = await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
@ -210,7 +167,6 @@ describe('api', () => {
const params = { ...apiParams, comments: [] };
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
@ -225,7 +181,7 @@ describe('api', () => {
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
@ -234,8 +190,8 @@ describe('api', () => {
priority: 'High',
issueType: '10006',
parent: null,
description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
description: 'Incident description',
summary: 'Incident title',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
@ -243,7 +199,7 @@ describe('api', () => {
test('it calls updateIncident correctly without mapping', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
@ -261,64 +217,34 @@ describe('api', () => {
test('it calls createComment correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
});
test('it calls createComment correctly without mapping', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping: null, params, logger: mockedLogger });
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
test('it calls createComment correctly without mapping', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
@ -327,16 +253,6 @@ describe('api', () => {
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
},
});
});
@ -411,396 +327,4 @@ describe('api', () => {
});
});
});
describe('mapping variations', () => {
test('overwrite & append', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
description:
'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('nothing & append', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
description:
'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('append & append', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
summary:
'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
description:
'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('nothing & nothing', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
},
});
});
test('overwrite & nothing', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('overwrite & overwrite', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('nothing & overwrite', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('append & overwrite', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
summary:
'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
description: 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('append & nothing', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
summary:
'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
},
});
});
test('comment nothing', async () => {
mapping.set('title', {
target: 'summary',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'nothing',
});
mapping.set('summary', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.createComment).not.toHaveBeenCalled();
});
});
});

View file

@ -5,7 +5,6 @@
*/
import {
ExternalServiceParams,
PushToServiceApiHandlerArgs,
HandshakeApiHandlerArgs,
GetIncidentApiHandlerArgs,
@ -14,26 +13,17 @@ import {
GetFieldsByIssueTypeHandlerArgs,
GetIssueTypesHandlerArgs,
GetIssuesHandlerArgs,
PushToServiceApiParams,
PushToServiceResponse,
GetIssueHandlerArgs,
GetCommonFieldsHandlerArgs,
} from './types';
// TODO: to remove, need to support Case
import { prepareFieldsForTransformation, transformFields, transformComments } from '../case/utils';
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
const handshakeHandler = async ({
externalService,
mapping,
params,
}: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({
externalService,
mapping,
params,
}: GetIncidentApiHandlerArgs) => {};
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {
const res = await externalService.getIncident(params.externalId);
return res;
};
const getIssueTypesHandler = async ({ externalService }: GetIssueTypesHandlerArgs) => {
const res = await externalService.getIssueTypes();
@ -68,58 +58,12 @@ const getIssueHandler = async ({ externalService, params }: GetIssueHandlerArgs)
const pushToServiceHandler = async ({
externalService,
mapping,
params,
logger,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const { externalId, comments } = params;
const updateIncident = externalId ? true : false;
const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated'];
let currentIncident: ExternalServiceParams | undefined;
const { comments } = params;
let res: PushToServiceResponse;
if (externalId) {
try {
currentIncident = await externalService.getIncident(externalId);
} catch (ex) {
logger.debug(
`Retrieving Incident by id ${externalId} from Jira failed with exception: ${ex}`
);
}
}
let incident: Incident;
// TODO: should be removed later but currently keep it for the Case implementation support
if (mapping) {
const fields = prepareFieldsForTransformation({
externalCase: params.externalObject,
mapping,
defaultPipes,
});
const transformedFields = transformFields<
PushToServiceApiParams,
ExternalServiceParams,
Incident
>({
params,
fields,
currentIncident,
});
const { priority, labels, issueType, parent } = params;
incident = {
summary: transformedFields.summary,
description: transformedFields.description,
priority,
labels,
issueType,
parent,
};
} else {
const { title, description, priority, labels, issueType, parent } = params;
incident = { summary: title, description, priority, labels, issueType, parent };
}
const { externalId, ...rest } = params.incident;
const incident: Incident = rest;
if (externalId != null) {
res = await externalService.updateIncident({
@ -128,23 +72,13 @@ const pushToServiceHandler = async ({
});
} else {
res = await externalService.createIncident({
incident: {
...incident,
},
incident,
});
}
if (comments && Array.isArray(comments) && comments.length > 0) {
if (mapping && mapping.get('comments')?.actionType === 'nothing') {
return res;
}
const commentsTransformed = mapping
? transformComments(comments, ['informationAdded'])
: comments;
res.comments = [];
for (const currentComment of commentsTransformed) {
for (const currentComment of comments) {
const comment = await externalService.createComment({
incidentId: res.id,
comment: currentComment,

View file

@ -27,13 +27,11 @@ import {
ExecutorSubActionGetIssueTypesParams,
ExecutorSubActionGetIssuesParams,
ExecutorSubActionGetIssueParams,
ExecutorSubActionGetIncidentParams,
} from './types';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
// TODO: to remove, need to support Case
import { buildMap, mapParams } from '../case/utils';
interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
@ -41,6 +39,7 @@ interface GetActionTypeParams {
const supportedSubActions: string[] = [
'getFields',
'getIncident',
'pushToService',
'issueTypes',
'fieldsByIssueType',
@ -109,21 +108,22 @@ async function executor(
throw new Error(errorMessage);
}
if (subAction === 'getIncident') {
const getIncidentParams = subActionParams as ExecutorSubActionGetIncidentParams;
const res = await api.getIncident({
externalService,
params: getIncidentParams,
});
if (res != null) {
data = res;
}
}
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
const { comments, externalId, ...restParams } = pushToServiceParams;
const incidentConfiguration = config.incidentConfiguration;
const mapping = incidentConfiguration ? buildMap(incidentConfiguration.mapping) : null;
const externalObject =
config.incidentConfiguration && mapping
? mapParams<ExecutorSubActionPushParams>(restParams as ExecutorSubActionPushParams, mapping)
: {};
data = await api.pushToService({
externalService,
mapping,
params: { ...pushToServiceParams, externalObject },
params: pushToServiceParams,
logger,
});

View file

@ -6,8 +6,6 @@
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { MapRecord } from '../case/types';
const createMock = (): jest.Mocked<ExternalService> => {
const service = {
getIncident: jest.fn().mockImplementation(() =>
@ -111,64 +109,31 @@ const createMock = (): jest.Mocked<ExternalService> => {
const externalServiceMock = {
create: createMock,
};
const mapping: Map<string, Partial<MapRecord>> = new Map();
mapping.set('title', {
target: 'summary',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('summary', {
target: 'title',
actionType: 'overwrite',
});
const executorParams: ExecutorSubActionPushParams = {
savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
externalId: 'incident-3',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
title: 'Incident title',
description: 'Incident description',
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
incident: {
externalId: 'incident-3',
summary: 'Incident title',
description: 'Incident description',
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
},
comments: [
{
commentId: 'case-comment-1',
comment: 'A comment',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
createdAt: '2020-04-27T10:59:46.202Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-04-27T10:59:46.202Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
},
],
};
const apiParams: PushToServiceApiParams = {
...executorParams,
externalObject: { summary: 'Incident title', description: 'Incident description' },
};
export { externalServiceMock, mapping, executorParams, apiParams };
export { externalServiceMock, executorParams, apiParams };

View file

@ -5,14 +5,10 @@
*/
import { schema } from '@kbn/config-schema';
import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
projectKey: schema.string(),
// TODO: to remove - set it optional for the current stage to support Case Jira implementation
incidentConfiguration: schema.nullable(IncidentConfigurationSchema),
isCaseOwned: schema.nullable(schema.boolean()),
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
@ -37,17 +33,23 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.nullable(schema.string()),
title: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
issueType: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
labels: schema.nullable(schema.arrayOf(schema.string())),
parent: schema.nullable(schema.string()),
// TODO: modify later to string[] - need for support Case schema
comments: schema.nullable(schema.arrayOf(CommentSchema)),
...EntityInformation,
incident: schema.object({
summary: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
issueType: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
labels: schema.nullable(schema.arrayOf(schema.string())),
parent: schema.nullable(schema.string()),
}),
comments: schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
),
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({

View file

@ -479,10 +479,6 @@ describe('Jira service', () => {
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
});
@ -507,10 +503,6 @@ describe('Jira service', () => {
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
});
@ -536,10 +528,6 @@ describe('Jira service', () => {
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
})
).rejects.toThrow(

View file

@ -133,21 +133,24 @@ export const createExternalService = (
[key: string]: {
allowedValues?: Array<{}>;
defaultValue?: {};
name: string;
required: boolean;
schema: FieldSchema;
};
}) =>
Object.keys(fields ?? {}).reduce((fieldsAcc, fieldKey) => {
return {
Object.keys(fields ?? {}).reduce(
(fieldsAcc, fieldKey) => ({
...fieldsAcc,
[fieldKey]: {
required: fields[fieldKey]?.required,
allowedValues: fields[fieldKey]?.allowedValues ?? [],
defaultValue: fields[fieldKey]?.defaultValue ?? {},
schema: fields[fieldKey]?.schema,
name: fields[fieldKey]?.name,
},
};
}, {});
}),
{}
);
const normalizeSearchResults = (
issues: Array<{ id: string; key: string; fields: { summary: string } }>
@ -386,7 +389,6 @@ export const createExternalService = (
});
const fields = res.data.projects[0]?.issuetypes[0]?.fields || {};
return normalizeFields(fields);
} else {
const res = await request({

View file

@ -17,11 +17,3 @@ export const ALLOWED_HOSTS_ERROR = (message: string) =>
message,
},
});
// TODO: remove when Case mappings will be removed
export const MAPPING_EMPTY = i18n.translate(
'xpack.actions.builtin.jira.configuration.emptyMapping',
{
defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty',
}
);

View file

@ -21,8 +21,6 @@ import {
ExecutorSubActionGetIssueParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { IncidentConfigurationSchema } from '../case/schema';
import { Comment } from '../case/types';
import { Logger } from '../../../../../../src/core/server';
export type JiraPublicConfigurationType = TypeOf<typeof ExternalIncidentServiceConfigurationSchema>;
@ -33,8 +31,6 @@ export type JiraSecretConfigurationType = TypeOf<
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export interface ExternalServiceCredentials {
config: Record<string, unknown>;
secrets: Record<string, unknown>;
@ -52,18 +48,9 @@ export interface ExternalServiceIncidentResponse {
pushedDate: string;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export type ExternalServiceParams = Record<string, unknown>;
export type Incident = Pick<
ExecutorSubActionPushParams,
'description' | 'priority' | 'labels' | 'issueType' | 'parent'
> & { summary: string };
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export interface CreateIncidentParams {
incident: Incident;
@ -76,7 +63,7 @@ export interface UpdateIncidentParams {
export interface CreateCommentParams {
incidentId: string;
comment: Comment;
comment: SimpleComment;
}
export interface FieldsSchema {
@ -84,18 +71,6 @@ export interface FieldsSchema {
[key: string]: string;
}
export interface ExternalServiceFields {
clauseNames: string[];
custom: boolean;
id: string;
key: string;
name: string;
navigatable: boolean;
orderable: boolean;
schema: FieldsSchema;
searchable: boolean;
}
export type GetIssueTypesResponse = Array<{ id: string; name: string }>;
export interface FieldSchema {
@ -104,7 +79,13 @@ export interface FieldSchema {
}
export type GetFieldsByIssueTypeResponse = Record<
string,
{ allowedValues: Array<{}>; defaultValue: {}; required: boolean; schema: FieldSchema }
{
allowedValues: Array<{}>;
defaultValue: {};
required: boolean;
schema: FieldSchema;
name: string;
}
>;
export type GetCommonFieldsResponse = GetFieldsByIssueTypeResponse;
@ -128,9 +109,7 @@ export interface ExternalService {
updateIncident: (params: UpdateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
}
export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
externalObject: Record<string, any>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
export type ExecutorSubActionGetIncidentParams = TypeOf<
typeof ExecutorSubActionGetIncidentParamsSchema
@ -160,7 +139,6 @@ export type ExecutorSubActionGetIssueParams = TypeOf<typeof ExecutorSubActionGet
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
mapping: Map<string, any> | null;
}
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
@ -207,7 +185,7 @@ export interface GetIssueHandlerArgs {
export interface ExternalServiceApi {
getFields: (args: GetCommonFieldsHandlerArgs) => Promise<GetCommonFieldsResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<ExternalServiceParams | undefined>;
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
issueTypes: (args: GetIssueTypesHandlerArgs) => Promise<GetIssueTypesResponse>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
@ -223,7 +201,8 @@ export type JiraExecutorResultData =
| GetIssueTypesResponse
| GetFieldsByIssueTypeResponse
| GetIssuesResponse
| GetIssueResponse;
| GetIssueResponse
| ExternalServiceParams;
export interface Fields {
[key: string]: string | string[] | { name: string } | { key: string } | { id: string };
@ -232,3 +211,12 @@ export interface ResponseError {
errorMessages: string[] | null | undefined;
errors: { [k: string]: string } | null | undefined;
}
export interface SimpleComment {
comment: string;
commentId: string;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
JiraPublicConfigurationType,
@ -18,13 +17,6 @@ export const validateCommonConfig = (
configurationUtilities: ActionsConfigurationUtilities,
configObject: JiraPublicConfigurationType
) => {
if (
configObject.incidentConfiguration !== null &&
isEmpty(configObject.incidentConfiguration.mapping)
) {
return i18n.MAPPING_EMPTY;
}
try {
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
} catch (allowedListError) {

View file

@ -6,7 +6,7 @@
import { Logger } from '../../../../../../src/core/server';
import { api } from './api';
import { externalServiceMock, mapping, apiParams } from './mocks';
import { externalServiceMock, apiParams } from './mocks';
import { ExternalService } from './types';
let mockedLogger: jest.Mocked<Logger>;
@ -28,7 +28,6 @@ describe('api', () => {
const params = { ...apiParams, externalId: null };
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
@ -55,7 +54,6 @@ describe('api', () => {
const params = { ...apiParams, externalId: null, comments: [] };
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
@ -69,16 +67,15 @@ describe('api', () => {
});
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
incidentTypes: [1001],
severityCode: 6,
description:
'Incident description (created at 2020-06-03T15:09:13.606Z by Elastic User)',
name: 'Incident title (created at 2020-06-03T15:09:13.606Z by Elastic User)',
description: 'Incident description',
name: 'Incident title',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
@ -86,23 +83,13 @@ describe('api', () => {
test('it calls createComment correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: '1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
comment: 'A comment',
},
});
@ -110,17 +97,7 @@ describe('api', () => {
incidentId: '1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
comment: 'Another comment',
},
});
});
@ -130,7 +107,6 @@ describe('api', () => {
test('it updates an incident', async () => {
const res = await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
@ -157,7 +133,6 @@ describe('api', () => {
const params = { ...apiParams, comments: [] };
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
@ -172,16 +147,15 @@ describe('api', () => {
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
description:
'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
description: 'Incident description',
name: 'Incident title',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
@ -189,23 +163,13 @@ describe('api', () => {
test('it calls createComment correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: '1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
comment: 'A comment',
},
});
@ -213,17 +177,7 @@ describe('api', () => {
incidentId: '1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: {
fullName: 'Elastic User',
username: 'elastic',
},
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: {
fullName: 'Elastic User',
username: 'elastic',
},
comment: 'Another comment',
},
});
});
@ -236,14 +190,8 @@ describe('api', () => {
params: {},
});
expect(res).toEqual([
{
id: 17,
name: 'Communication error (fax; email)',
},
{
id: 1001,
name: 'Custom type',
},
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
]);
});
});
@ -255,397 +203,11 @@ describe('api', () => {
params: { id: '10006' },
});
expect(res).toEqual([
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
{ id: 4, name: 'Low' },
{ id: 5, name: 'Medium' },
{ id: 6, name: 'High' },
]);
});
});
describe('mapping variations', () => {
test('overwrite & append', async () => {
mapping.set('title', {
target: 'name',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
description:
'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('nothing & append', async () => {
mapping.set('title', {
target: 'name',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
description:
'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('append & append', async () => {
mapping.set('title', {
target: 'name',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
name:
'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
description:
'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('nothing & nothing', async () => {
mapping.set('title', {
target: 'name',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
},
});
});
test('overwrite & nothing', async () => {
mapping.set('title', {
target: 'name',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('overwrite & overwrite', async () => {
mapping.set('title', {
target: 'name',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
description:
'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('nothing & overwrite', async () => {
mapping.set('title', {
target: 'name',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
description:
'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('append & overwrite', async () => {
mapping.set('title', {
target: 'name',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
name:
'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
description:
'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('append & nothing', async () => {
mapping.set('title', {
target: 'name',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
incidentTypes: [1001],
severityCode: 6,
name:
'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)',
},
});
});
test('comment nothing', async () => {
mapping.set('title', {
target: 'name',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'nothing',
});
mapping.set('name', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.createComment).not.toHaveBeenCalled();
});
});
});
});

View file

@ -5,7 +5,6 @@
*/
import {
ExternalServiceParams,
PushToServiceApiHandlerArgs,
HandshakeApiHandlerArgs,
GetIncidentApiHandlerArgs,
@ -13,25 +12,13 @@ import {
Incident,
GetIncidentTypesHandlerArgs,
GetSeverityHandlerArgs,
PushToServiceApiParams,
PushToServiceResponse,
GetCommonFieldsHandlerArgs,
} from './types';
// TODO: to remove, need to support Case
import { transformFields, prepareFieldsForTransformation, transformComments } from '../case/utils';
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
const handshakeHandler = async ({
externalService,
mapping,
params,
}: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({
externalService,
mapping,
params,
}: GetIncidentApiHandlerArgs) => {};
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {};
const getFieldsHandler = async ({ externalService }: GetCommonFieldsHandlerArgs) => {
const res = await externalService.getFields();
@ -49,56 +36,12 @@ const getSeverityHandler = async ({ externalService }: GetSeverityHandlerArgs) =
const pushToServiceHandler = async ({
externalService,
mapping,
params,
logger,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const { externalId, comments } = params;
const updateIncident = externalId ? true : false;
const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated'];
let currentIncident: ExternalServiceParams | undefined;
const { comments } = params;
let res: PushToServiceResponse;
if (externalId) {
try {
currentIncident = await externalService.getIncident(externalId);
} catch (ex) {
logger.debug(
`Retrieving Incident by id ${externalId} from IBM Resilient was failed with exception: ${ex}`
);
}
}
let incident: Incident;
// TODO: should be removed later but currently keep it for the Case implementation support
if (mapping) {
const fields = prepareFieldsForTransformation({
externalCase: params.externalObject,
mapping,
defaultPipes,
});
const transformedFields = transformFields<
PushToServiceApiParams,
ExternalServiceParams,
Incident
>({
params,
fields,
currentIncident,
});
const { incidentTypes, severityCode } = params;
incident = {
name: transformedFields.name,
description: transformedFields.description,
incidentTypes,
severityCode,
};
} else {
const { title, description, incidentTypes, severityCode } = params;
incident = { name: title, description, incidentTypes, severityCode };
}
const { externalId, ...rest } = params.incident;
const incident: Incident = rest;
if (externalId != null) {
res = await externalService.updateIncident({
@ -107,22 +50,13 @@ const pushToServiceHandler = async ({
});
} else {
res = await externalService.createIncident({
incident: {
...incident,
},
incident,
});
}
if (comments && Array.isArray(comments) && comments.length > 0) {
if (mapping && mapping.get('comments')?.actionType === 'nothing') {
return res;
}
const commentsTransformed = mapping
? transformComments(comments, ['informationAdded'])
: comments;
res.comments = [];
for (const currentComment of commentsTransformed) {
for (const currentComment of comments) {
const comment = await externalService.createComment({
incidentId: res.id,
comment: currentComment,

View file

@ -30,9 +30,6 @@ import {
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
// TODO: to remove, need to support Case
import { buildMap, mapParams } from '../case/utils';
interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
@ -104,19 +101,9 @@ async function executor(
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
const { comments, externalId, ...restParams } = pushToServiceParams;
const mapping = config.incidentConfiguration
? buildMap(config.incidentConfiguration.mapping)
: null;
const externalObject =
config.incidentConfiguration && mapping
? mapParams<ExecutorSubActionPushParams>(restParams as ExecutorSubActionPushParams, mapping)
: {};
data = await api.pushToService({
externalService,
mapping,
params: { ...pushToServiceParams, externalObject },
params: pushToServiceParams,
logger,
});

View file

@ -6,8 +6,6 @@
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { MapRecord } from '../case/types';
export const resilientFields = [
{
id: 17,
@ -348,62 +346,28 @@ const externalServiceMock = {
create: createMock,
};
const mapping: Map<string, Partial<MapRecord>> = new Map();
mapping.set('title', {
target: 'name',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('name', {
target: 'title',
actionType: 'overwrite',
});
const executorParams: ExecutorSubActionPushParams = {
savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
externalId: 'incident-3',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
title: 'Incident title',
description: 'Incident description',
incidentTypes: [1001],
severityCode: 6,
incident: {
externalId: 'incident-3',
name: 'Incident title',
description: 'Incident description',
incidentTypes: [1001],
severityCode: 6,
},
comments: [
{
commentId: 'case-comment-1',
comment: 'A comment',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
createdAt: '2020-06-03T15:09:13.606Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-06-03T15:09:13.606Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
},
],
};
const apiParams: PushToServiceApiParams = {
...executorParams,
externalObject: { name: 'Incident title', description: 'Incident description' },
};
const incidentTypes = [
@ -457,4 +421,4 @@ const severity = [
},
];
export { externalServiceMock, mapping, executorParams, apiParams, incidentTypes, severity };
export { externalServiceMock, executorParams, apiParams, incidentTypes, severity };

View file

@ -5,14 +5,10 @@
*/
import { schema } from '@kbn/config-schema';
import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
orgId: schema.string(),
// TODO: to remove - set it optional for the current stage to support Case implementation
incidentConfiguration: schema.nullable(IncidentConfigurationSchema),
isCaseOwned: schema.nullable(schema.boolean()),
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
@ -37,15 +33,21 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.nullable(schema.string()),
title: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
incidentTypes: schema.nullable(schema.arrayOf(schema.number())),
severityCode: schema.nullable(schema.number()),
// TODO: remove later - need for support Case push multiple comments
comments: schema.nullable(schema.arrayOf(CommentSchema)),
...EntityInformation,
incident: schema.object({
name: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
incidentTypes: schema.nullable(schema.arrayOf(schema.number())),
severityCode: schema.nullable(schema.number()),
}),
comments: schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
),
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({

View file

@ -450,10 +450,6 @@ describe('IBM Resilient service', () => {
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
});
@ -477,10 +473,6 @@ describe('IBM Resilient service', () => {
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
});
@ -510,10 +502,6 @@ describe('IBM Resilient service', () => {
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
})
).rejects.toThrow(

View file

@ -17,11 +17,3 @@ export const ALLOWED_HOSTS_ERROR = (message: string) =>
message,
},
});
// TODO: remove when Case mappings will be removed
export const MAPPING_EMPTY = i18n.translate(
'xpack.actions.builtin.servicenow.configuration.emptyMapping',
{
defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty',
}
);

View file

@ -22,9 +22,6 @@ import {
import { ActionsConfigurationUtilities } from '../../actions_config';
import { Logger } from '../../../../../../src/core/server';
import { IncidentConfigurationSchema } from '../case/schema';
import { Comment } from '../case/types';
export type ResilientPublicConfigurationType = TypeOf<
typeof ExternalIncidentServiceConfigurationSchema
>;
@ -39,8 +36,6 @@ export type ExecutorSubActionCommonFieldsParams = TypeOf<
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export interface ExternalServiceCredentials {
config: Record<string, unknown>;
secrets: Record<string, unknown>;
@ -58,28 +53,17 @@ export interface ExternalServiceIncidentResponse {
pushedDate: string;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export type ExternalServiceParams = Record<string, unknown>;
export interface ExternalServiceFields {
id: string;
input_type: string;
name: string;
read_only: boolean;
required?: string;
text: string;
}
export type GetCommonFieldsResponse = ExternalServiceFields[];
export type Incident = Pick<
ExecutorSubActionPushParams,
'description' | 'incidentTypes' | 'severityCode'
> & {
name: string;
};
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export interface CreateIncidentParams {
incident: Incident;
@ -92,7 +76,7 @@ export interface UpdateIncidentParams {
export interface CreateCommentParams {
incidentId: string;
comment: Comment;
comment: SimpleComment;
}
export type GetIncidentTypesResponse = Array<{ id: string; name: string }>;
@ -108,10 +92,7 @@ export interface ExternalService {
updateIncident: (params: UpdateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
}
export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
externalObject: Record<string, any>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
export type ExecutorSubActionGetIncidentTypesParams = TypeOf<
typeof ExecutorSubActionGetIncidentTypesParamsSchema
>;
@ -122,7 +103,6 @@ export type ExecutorSubActionGetSeverityParams = TypeOf<
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
mapping: Map<string, any> | null;
}
export type ExecutorSubActionGetIncidentParams = TypeOf<
@ -222,3 +202,12 @@ export interface CreateIncidentData {
incident_type_ids?: Array<{ id: number }>;
severity_code?: { id: number };
}
export interface SimpleComment {
comment: string;
commentId: string;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
ResilientPublicConfigurationType,
@ -18,13 +17,6 @@ export const validateCommonConfig = (
configurationUtilities: ActionsConfigurationUtilities,
configObject: ResilientPublicConfigurationType
) => {
if (
configObject.incidentConfiguration !== null &&
isEmpty(configObject.incidentConfiguration.mapping)
) {
return i18n.MAPPING_EMPTY;
}
try {
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
} catch (allowedListError) {

View file

@ -5,7 +5,7 @@
*/
import { Logger } from '../../../../../../src/core/server';
import { externalServiceMock, mapping, apiParams, serviceNowCommonFields } from './mocks';
import { externalServiceMock, apiParams, serviceNowCommonFields } from './mocks';
import { ExternalService } from './types';
import { api } from './api';
let mockedLogger: jest.Mocked<Logger>;
@ -19,10 +19,9 @@ describe('api', () => {
describe('create incident', () => {
test('it creates an incident', async () => {
const params = { ...apiParams, externalId: null };
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
const res = await api.pushToService({
externalService,
mapping,
params,
secrets: {},
logger: mockedLogger,
@ -47,10 +46,13 @@ describe('api', () => {
});
test('it creates an incident without comments', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
const params = {
...apiParams,
incident: { ...apiParams.incident, externalId: null },
comments: [],
};
const res = await api.pushToService({
externalService,
mapping,
params,
secrets: {},
logger: mockedLogger,
@ -65,10 +67,12 @@ describe('api', () => {
});
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
const params = {
incident: { ...apiParams.incident, externalId: null },
comments: [],
};
await api.pushToService({
externalService,
mapping,
params,
secrets: { username: 'elastic', password: 'elastic' },
logger: mockedLogger,
@ -80,18 +84,17 @@ describe('api', () => {
urgency: '2',
impact: '3',
caller_id: 'elastic',
description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description: 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description',
short_description: 'Incident title',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
test('it calls updateIncident correctly when creating an incident and having comments', async () => {
const params = { ...apiParams, externalId: null };
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({
externalService,
mapping,
params,
secrets: {},
logger: mockedLogger,
@ -102,9 +105,9 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description: 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-1',
});
@ -114,9 +117,9 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
comments: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description: 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
comments: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-1',
});
@ -127,7 +130,6 @@ describe('api', () => {
test('it updates an incident', async () => {
const res = await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
@ -155,7 +157,6 @@ describe('api', () => {
const params = { ...apiParams, comments: [] };
const res = await api.pushToService({
externalService,
mapping,
params,
secrets: {},
logger: mockedLogger,
@ -173,7 +174,6 @@ describe('api', () => {
const params = { ...apiParams };
await api.pushToService({
externalService,
mapping,
params,
secrets: {},
logger: mockedLogger,
@ -185,8 +185,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description',
short_description: 'Incident title',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
@ -196,7 +196,6 @@ describe('api', () => {
const params = { ...apiParams };
await api.pushToService({
externalService,
mapping,
params,
secrets: {},
logger: mockedLogger,
@ -207,8 +206,8 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-3',
});
@ -218,409 +217,15 @@ describe('api', () => {
severity: '1',
urgency: '2',
impact: '3',
comments: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
},
incidentId: 'incident-2',
});
});
});
describe('mapping variations', () => {
test('overwrite & append', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
description:
'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('nothing & append', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
description:
'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('append & append', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'append',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
short_description:
'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
description:
'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('nothing & nothing', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
},
});
});
test('overwrite & nothing', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('overwrite & overwrite', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
short_description: 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('nothing & overwrite', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'nothing',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'nothing',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('append & overwrite', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
short_description:
'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('append & nothing', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'append',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'append',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
severity: '1',
urgency: '2',
impact: '3',
short_description:
'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
},
});
});
test('comment nothing', async () => {
mapping.set('title', {
target: 'short_description',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'nothing',
});
mapping.set('comments', {
target: 'comments',
actionType: 'nothing',
});
mapping.set('short_description', {
target: 'title',
actionType: 'overwrite',
});
await api.pushToService({
externalService,
mapping,
params: apiParams,
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledTimes(1);
});
});
describe('getFields', () => {
test('it returns the fields correctly', async () => {
const res = await api.getFields({

View file

@ -4,86 +4,30 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
ExternalServiceParams,
PushToServiceApiHandlerArgs,
HandshakeApiHandlerArgs,
GetIncidentApiHandlerArgs,
ExternalServiceApi,
PushToServiceApiParams,
PushToServiceResponse,
Incident,
GetCommonFieldsHandlerArgs,
GetCommonFieldsResponse,
GetIncidentApiHandlerArgs,
HandshakeApiHandlerArgs,
Incident,
PushToServiceApiHandlerArgs,
PushToServiceResponse,
} from './types';
// TODO: to remove, need to support Case
import { transformFields, transformComments, prepareFieldsForTransformation } from '../case/utils';
const handshakeHandler = async ({
externalService,
mapping,
params,
}: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({
externalService,
mapping,
params,
}: GetIncidentApiHandlerArgs) => {};
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {};
const pushToServiceHandler = async ({
externalService,
mapping,
params,
secrets,
logger,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const { externalId, comments } = params;
const updateIncident = externalId ? true : false;
const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated'];
let currentIncident: ExternalServiceParams | undefined;
const { comments } = params;
let res: PushToServiceResponse;
const { externalId, ...rest } = params.incident;
const incident: Incident = rest;
if (externalId) {
try {
currentIncident = await externalService.getIncident(externalId);
} catch (ex) {
logger.debug(
`Retrieving Incident by id ${externalId} from ServiceNow was failed with exception: ${ex}`
);
}
}
let incident = {};
// TODO: should be removed later but currently keep it for the Case implementation support
if (mapping && Array.isArray(params.comments)) {
const fields = prepareFieldsForTransformation({
externalCase: params.externalObject,
mapping,
defaultPipes,
});
const transformedFields = transformFields<
PushToServiceApiParams,
ExternalServiceParams,
Incident
>({
params,
fields,
currentIncident,
});
incident = {
severity: params.severity,
urgency: params.urgency,
impact: params.impact,
short_description: transformedFields.short_description,
description: transformedFields.description,
};
} else {
incident = { ...params, short_description: params.title, comments: params.comment };
}
if (updateIncident) {
if (externalId != null) {
res = await externalService.updateIncident({
incidentId: externalId,
incident,
@ -97,24 +41,15 @@ const pushToServiceHandler = async ({
});
}
// TODO: should temporary keep comments for a Case usage
if (
comments &&
Array.isArray(comments) &&
comments.length > 0 &&
mapping &&
mapping.get('comments')?.actionType !== 'nothing'
) {
if (comments && Array.isArray(comments) && comments.length > 0) {
res.comments = [];
const commentsTransformed = transformComments(comments, ['informationAdded']);
const fieldsKey = mapping.get('comments')?.target ?? 'comments';
for (const currentComment of commentsTransformed) {
for (const currentComment of comments) {
await externalService.updateIncident({
incidentId: res.id,
incident: {
...incident,
[fieldsKey]: currentComment.comment,
comments: currentComment.comment,
},
});
res.comments = [

View file

@ -29,9 +29,6 @@ import {
ServiceNowExecutorResultData,
} from './types';
// TODO: to remove, need to support Case
import { buildMap, mapParams } from '../case/utils';
interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
@ -101,17 +98,9 @@ async function executor(
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
const { comments, externalId, ...restParams } = pushToServiceParams;
const incidentConfiguration = config.incidentConfiguration;
const mapping = incidentConfiguration ? buildMap(incidentConfiguration.mapping) : null;
const externalObject =
config.incidentConfiguration && mapping ? mapParams(restParams, mapping) : {};
data = await api.pushToService({
externalService,
mapping,
params: { ...pushToServiceParams, externalObject },
params: pushToServiceParams,
secrets,
logger,
});

View file

@ -5,7 +5,6 @@
*/
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { MapRecord } from '../case/types';
export const serviceNowCommonFields = [
{
@ -69,64 +68,29 @@ const externalServiceMock = {
create: createMock,
};
const mapping: Map<string, Partial<MapRecord>> = new Map();
mapping.set('title', {
target: 'short_description',
actionType: 'overwrite',
});
mapping.set('description', {
target: 'description',
actionType: 'overwrite',
});
mapping.set('comments', {
target: 'comments',
actionType: 'append',
});
mapping.set('short_description', {
target: 'title',
actionType: 'overwrite',
});
const executorParams: ExecutorSubActionPushParams = {
savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa',
externalId: 'incident-3',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-03-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
title: 'Incident title',
description: 'Incident description',
comment: 'test-alert comment',
severity: '1',
urgency: '2',
impact: '3',
incident: {
externalId: 'incident-3',
short_description: 'Incident title',
description: 'Incident description',
severity: '1',
urgency: '2',
impact: '3',
},
comments: [
{
commentId: 'case-comment-1',
comment: 'A comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-03-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: '2020-03-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
},
],
};
const apiParams: PushToServiceApiParams = {
...executorParams,
externalObject: { short_description: 'Incident title', description: 'Incident description' },
};
export { externalServiceMock, mapping, executorParams, apiParams };
export { externalServiceMock, executorParams, apiParams };

View file

@ -5,13 +5,9 @@
*/
import { schema } from '@kbn/config-schema';
import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
// TODO: to remove - set it optional for the current stage to support Case ServiceNow implementation
incidentConfiguration: schema.nullable(IncidentConfigurationSchema),
isCaseOwned: schema.maybe(schema.boolean()),
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
@ -35,17 +31,22 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.nullable(schema.string()),
title: schema.string(),
description: schema.nullable(schema.string()),
comment: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
severity: schema.nullable(schema.string()),
urgency: schema.nullable(schema.string()),
impact: schema.nullable(schema.string()),
// TODO: remove later - need for support Case push multiple comments
comments: schema.maybe(schema.arrayOf(CommentSchema)),
...EntityInformation,
incident: schema.object({
short_description: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
severity: schema.nullable(schema.string()),
urgency: schema.nullable(schema.string()),
impact: schema.nullable(schema.string()),
}),
comments: schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
),
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({

View file

@ -249,7 +249,7 @@ describe('ServiceNow service', () => {
axios,
logger,
url:
'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label',
'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
test('it returns common fields correctly', async () => {
@ -265,7 +265,7 @@ describe('ServiceNow service', () => {
throw new Error('An error has occurred');
});
await expect(service.getFields()).rejects.toThrow(
'Unable to get common fields. Error: An error has occurred'
'[Action][ServiceNow]: Unable to get fields. Error: An error has occurred'
);
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types';
@ -35,7 +35,7 @@ export const createExternalService = (
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const incidentUrl = `${urlWithoutTrailingSlash}/${INCIDENT_URL}`;
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label`;
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
const axiosInstance = axios.create({
auth: { username, password },
});
@ -44,6 +44,14 @@ export const createExternalService = (
return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}${id}`;
};
const checkInstance = (res: AxiosResponse) => {
if (res.status === 200 && res.data.result == null) {
throw new Error(
`There is an issue with your Service Now Instance. Please check ${res.request.connection.servername}`
);
}
};
const getIncident = async (id: string) => {
try {
const res = await request({
@ -52,7 +60,7 @@ export const createExternalService = (
logger,
proxySettings,
});
checkInstance(res);
return { ...res.data.result };
} catch (error) {
throw new Error(
@ -70,7 +78,7 @@ export const createExternalService = (
proxySettings,
params,
});
checkInstance(res);
return res.data.result.length > 0 ? { ...res.data.result } : undefined;
} catch (error) {
throw new Error(
@ -89,7 +97,7 @@ export const createExternalService = (
method: 'post',
data: { ...(incident as Record<string, unknown>) },
});
checkInstance(res);
return {
title: res.data.result.number,
id: res.data.result.sys_id,
@ -112,7 +120,7 @@ export const createExternalService = (
data: { ...(incident as Record<string, unknown>) },
proxySettings,
});
checkInstance(res);
return {
title: res.data.result.number,
id: res.data.result.sys_id,
@ -137,12 +145,10 @@ export const createExternalService = (
logger,
proxySettings,
});
checkInstance(res);
return res.data.result.length > 0 ? res.data.result : [];
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get common fields. Error: ${error.message}`)
);
throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}`));
}
};

View file

@ -17,11 +17,3 @@ export const ALLOWED_HOSTS_ERROR = (message: string) =>
message,
},
});
// TODO: remove when Case mappings will be removed
export const MAPPING_EMPTY = i18n.translate(
'xpack.actions.builtin.servicenow.configuration.emptyMapping',
{
defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty',
}
);

View file

@ -17,8 +17,6 @@ import {
ExternalIncidentServiceSecretConfigurationSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ExternalServiceCommentResponse } from '../case/types';
import { IncidentConfigurationSchema } from '../case/schema';
import { Logger } from '../../../../../../src/core/server';
export type ServiceNowPublicConfigurationType = TypeOf<
@ -41,8 +39,6 @@ export interface CreateCommentRequest {
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export interface ExternalServiceCredentials {
config: Record<string, unknown>;
secrets: Record<string, unknown>;
@ -73,13 +69,10 @@ export interface ExternalService {
findIncidents: (params?: Record<string, string>) => Promise<ExternalServiceParams[] | undefined>;
}
export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
externalObject: Record<string, any>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
mapping: Map<string, any> | null;
}
export type ExecutorSubActionGetIncidentParams = TypeOf<
@ -90,12 +83,7 @@ export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export type Incident = Pick<
ExecutorSubActionPushParams,
'description' | 'severity' | 'urgency' | 'impact'
> & {
short_description: string;
};
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
@ -112,11 +100,7 @@ export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
}
export interface ExternalServiceFields {
column_label: string;
name: string;
internal_type: {
link: string;
value: string;
};
mandatory: string;
max_length: string;
element: string;
}
@ -132,3 +116,9 @@ export interface ExternalServiceApi {
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
ServiceNowPublicConfigurationType,
@ -18,13 +17,6 @@ export const validateCommonConfig = (
configurationUtilities: ActionsConfigurationUtilities,
configObject: ServiceNowPublicConfigurationType
) => {
if (
configObject.incidentConfiguration !== null &&
isEmpty(configObject.incidentConfiguration.mapping)
) {
return i18n.MAPPING_EMPTY;
}
try {
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
} catch (allowedListError) {

View file

@ -93,6 +93,21 @@ describe('7.11.0', () => {
},
});
});
test('remove cases mapping object', () => {
const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0'];
const action = getMockData({
config: { incidentConfiguration: { mapping: [] }, isCaseOwned: true, another: 'value' },
});
expect(migration711(action, context)).toEqual({
...action,
attributes: {
...action.attributes,
config: {
another: 'value',
},
},
});
});
});
function getMockDataForWebhook(

View file

@ -19,24 +19,23 @@ type ActionMigration = (
export function getMigrations(
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup
): SavedObjectMigrationMap {
const migrationActions = encryptedSavedObjects.createMigration<RawAction, RawAction>(
const migrationActionsTen = encryptedSavedObjects.createMigration<RawAction, RawAction>(
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> =>
!!doc.attributes.config?.casesConfiguration || doc.attributes.actionTypeId === '.email',
pipeMigrations(renameCasesConfigurationObject, addHasAuthConfigurationObject)
);
const migrationWebhookConnectorHasAuth = encryptedSavedObjects.createMigration<
RawAction,
RawAction
>(
const migrationActionsEleven = encryptedSavedObjects.createMigration<RawAction, RawAction>(
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> =>
!!doc.attributes.config?.isCaseOwned ||
!!doc.attributes.config?.incidentConfiguration ||
doc.attributes.actionTypeId === '.webhook',
pipeMigrations(addHasAuthConfigurationObject)
pipeMigrations(removeCasesFieldMappings, addHasAuthConfigurationObject)
);
return {
'7.10.0': executeMigrationWithErrorHandling(migrationActions, '7.10.0'),
'7.11.0': executeMigrationWithErrorHandling(migrationWebhookConnectorHasAuth, '7.11.0'),
'7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'),
'7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'),
};
}
@ -77,6 +76,26 @@ function renameCasesConfigurationObject(
};
}
function removeCasesFieldMappings(
doc: SavedObjectUnsanitizedDoc<RawAction>
): SavedObjectUnsanitizedDoc<RawAction> {
if (
!doc.attributes.config?.hasOwnProperty('isCaseOwned') &&
!doc.attributes.config?.hasOwnProperty('incidentConfiguration')
) {
return doc;
}
const { incidentConfiguration, isCaseOwned, ...restConfiguration } = doc.attributes.config;
return {
...doc,
attributes: {
...doc.attributes,
config: restConfiguration,
},
};
}
const addHasAuthConfigurationObject = (
doc: SavedObjectUnsanitizedDoc<RawAction>
): SavedObjectUnsanitizedDoc<RawAction> => {

View file

@ -18,6 +18,9 @@ import {
} from '../../../../src/core/server';
import { ActionTypeExecutorResult } from '../common';
export { ActionTypeExecutorResult } from '../common';
export { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './builtin_action_types/jira/types';
export { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './builtin_action_types/servicenow/types';
export { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './builtin_action_types/resilient/types';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type GetServicesFunction = (request: KibanaRequest) => Services;

View file

@ -10,10 +10,7 @@ import { NumberFromString } from '../saved_object';
import { UserRT } from '../user';
import { CommentResponseRt } from './comment';
import { CasesStatusResponseRt } from './status';
import { CaseConnectorRt, ESCaseConnector, ConnectorPartialFieldsRt } from '../connectors';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
export { ActionTypeExecutorResult } from '../../../../actions/server/types';
import { CaseConnectorRt, ESCaseConnector } from '../connectors';
export enum CaseStatuses {
open = 'open',
@ -128,66 +125,6 @@ export const CasePatchRequestRt = rt.intersection([
export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) });
export const CasesResponseRt = rt.array(CaseResponseRt);
/*
* This type are related to this file below
* x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
* why because this schema is not share in a common folder
* so we redefine then so we can use/validate types
*/
// TODO: Refactor to support multiple connectors with various fields
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 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 CaseAttributes = rt.TypeOf<typeof CaseAttributesRt>;
export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
export type CaseResponse = rt.TypeOf<typeof CaseResponseRt>;
@ -196,10 +133,7 @@ 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 ServiceConnectorCaseParams = rt.TypeOf<typeof ServiceConnectorCaseParamsRt>;
export type ServiceConnectorCaseResponse = rt.TypeOf<typeof ServiceConnectorCaseResponseRt>;
export type CaseFullExternalService = rt.TypeOf<typeof CaseFullExternalServiceRt>;
export type ServiceConnectorCommentParams = rt.TypeOf<typeof ServiceConnectorCommentParamsRt>;
export type ESCaseAttributes = Omit<CaseAttributes, 'connector'> & { connector: ESCaseConnector };
export type ESCasePatchRequest = Omit<CasePatchRequest, 'connector'> & {

View file

@ -8,60 +8,7 @@ import * as rt from 'io-ts';
import { ActionResult } from '../../../../actions/common';
import { UserRT } from '../user';
import { JiraCaseFieldsRt } from '../connectors/jira';
import { ServiceNowCaseFieldsRT } from '../connectors/servicenow';
import { ResilientCaseFieldsRT } from '../connectors/resilient';
import { CaseConnectorRt, ESCaseConnector } from '../connectors';
/*
* This types below are related to the service now configuration
* mapping between our case and [service-now, jira]
*
*/
const ActionTypeRT = rt.union([
rt.literal('append'),
rt.literal('nothing'),
rt.literal('overwrite'),
]);
const CaseFieldRT = rt.union([
rt.literal('title'),
rt.literal('description'),
rt.literal('comments'),
]);
const ThirdPartyFieldRT = rt.union([
JiraCaseFieldsRt,
ServiceNowCaseFieldsRT,
ResilientCaseFieldsRT,
rt.literal('not_mapped'),
]);
export const CasesConfigurationMapsRT = rt.type({
source: CaseFieldRT,
target: ThirdPartyFieldRT,
action_type: ActionTypeRT,
});
export const CasesConfigurationRT = rt.type({
mapping: rt.array(CasesConfigurationMapsRT),
});
export const CasesConnectorConfigurationRT = rt.type({
cases_configuration: CasesConfigurationRT,
// version: rt.string,
});
export type ActionType = rt.TypeOf<typeof ActionTypeRT>;
export type CaseField = rt.TypeOf<typeof CaseFieldRT>;
export type ThirdPartyField = rt.TypeOf<typeof ThirdPartyFieldRT>;
export type CasesConfigurationMaps = rt.TypeOf<typeof CasesConfigurationMapsRT>;
export type CasesConfiguration = rt.TypeOf<typeof CasesConfigurationRT>;
export type CasesConnectorConfiguration = rt.TypeOf<typeof CasesConnectorConfigurationRT>;
/** ********************************************************************** */
import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors';
export type ActionConnector = ActionResult;
@ -91,6 +38,7 @@ export const CaseConfigureAttributesRt = rt.intersection([
export const CaseConfigureResponseRt = rt.intersection([
CaseConfigureAttributesRt,
ConnectorMappingsRt,
rt.type({
version: rt.string,
}),

View file

@ -12,6 +12,7 @@ import { ServiceNowFieldsRT } from './servicenow';
export * from './jira';
export * from './servicenow';
export * from './resilient';
export * from './mappings';
export const ConnectorFieldsRt = rt.union([
JiraFieldsRT,
@ -19,13 +20,6 @@ export const ConnectorFieldsRt = rt.union([
ServiceNowFieldsRT,
rt.null,
]);
export const ConnectorPartialFieldsRt = rt.partial({
...JiraFieldsRT.props,
...ResilientFieldsRT.props,
...ServiceNowFieldsRT.props,
});
export enum ConnectorTypes {
jira = '.jira',
resilient = '.resilient',

View file

@ -6,12 +6,6 @@
import * as rt from 'io-ts';
export const JiraCaseFieldsRt = rt.union([
rt.literal('summary'),
rt.literal('description'),
rt.literal('comments'),
]);
export const JiraFieldsRT = rt.type({
issueType: rt.union([rt.string, rt.null]),
priority: rt.union([rt.string, rt.null]),

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;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import * as rt from 'io-ts';
import { ElasticUser } from '../../../../security_solution/public/cases/containers/types';
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 {
PushToServiceApiParams as ServiceNowPushToServiceApiParams,
Incident as ServiceNowIncident,
} from '../../../../actions/server/builtin_action_types/servicenow/types';
import { ResilientFieldsRT } from './resilient';
import { ServiceNowFieldsRT } from './servicenow';
import { JiraFieldsRT } from './jira';
export {
JiraPushToServiceApiParams,
ResilientPushToServiceApiParams,
ServiceNowPushToServiceApiParams,
};
export type Incident = JiraIncident | ResilientIncident | ServiceNowIncident;
export type PushToServiceApiParams =
| JiraPushToServiceApiParams
| ResilientPushToServiceApiParams
| ServiceNowPushToServiceApiParams;
const ActionTypeRT = rt.union([
rt.literal('append'),
rt.literal('nothing'),
rt.literal('overwrite'),
]);
const CaseFieldRT = rt.union([
rt.literal('title'),
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>;
export type ThirdPartyField = rt.TypeOf<typeof ThirdPartyFieldRT>;
export const ConnectorMappingsAttributesRT = rt.type({
action_type: ActionTypeRT,
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>;
const FieldTypeRT = rt.union([rt.literal('text'), rt.literal('textarea')]);
const ConnectorFieldRt = rt.type({
id: rt.string,
name: rt.string,
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 interface SimpleComment {
comment: string;
commentId: string;
}
export interface MapIncident {
incident: ExternalServiceParams;
comments: SimpleComment[];
}

View file

@ -6,12 +6,6 @@
import * as rt from 'io-ts';
export const ResilientCaseFieldsRT = rt.union([
rt.literal('name'),
rt.literal('description'),
rt.literal('comments'),
]);
export const ResilientFieldsRT = rt.type({
incidentTypes: rt.union([rt.array(rt.string), rt.null]),
severityCode: rt.union([rt.string, rt.null]),

View file

@ -6,12 +6,6 @@
import * as rt from 'io-ts';
export const ServiceNowCaseFieldsRT = rt.union([
rt.literal('short_description'),
rt.literal('description'),
rt.literal('comments'),
]);
export const ServiceNowFieldsRT = rt.type({
impact: rt.union([rt.string, rt.null]),
severity: rt.union([rt.string, rt.null]),

View file

@ -9,6 +9,7 @@ import {
CASE_COMMENTS_URL,
CASE_USER_ACTIONS_URL,
CASE_COMMENT_DETAILS_URL,
CASE_CONFIGURE_PUSH_URL,
} from '../constants';
export const getCaseDetailsUrl = (id: string): string => {
@ -26,3 +27,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);
};

View file

@ -21,6 +21,7 @@ export const NumberFromString = new rt.Type<number, string, unknown>(
export const SavedObjectFindOptionsRt = rt.partial({
defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]),
hasReference: rt.type({ id: rt.string, type: rt.string }),
fields: rt.array(rt.string),
filter: rt.string,
page: NumberFromString,

View file

@ -14,6 +14,8 @@ 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_REPORTERS_URL = `${CASES_URL}/reporters`;

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;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from '@hapi/boom';
import { GetFieldsResponse } from '../../../common/api';
import { ConfigureFields } from '../types';
import { createDefaultMapping, formatFields } from './utils';
export const getFields = () => async ({
actionsClient,
connectorType,
connectorId,
}: ConfigureFields): Promise<GetFieldsResponse> => {
const results = await actionsClient.execute({
actionId: connectorId,
params: {
subAction: 'getFields',
subActionParams: {},
},
});
if (results.status === 'error') {
throw Boom.failedDependency(results.serviceMessage);
}
const fields = formatFields(results.data, connectorType);
return { fields, defaultMappings: createDefaultMapping(fields, connectorType) };
};

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api';
import { CaseClientFactoryArguments, MappingsClient } from '../types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects';
export const getMappings = ({
savedObjectsClient,
connectorMappingsService,
}: CaseClientFactoryArguments) => async ({
actionsClient,
caseClient,
connectorType,
connectorId,
}: MappingsClient): Promise<ConnectorMappingsAttributes[]> => {
if (connectorType === ConnectorTypes.none) {
return [];
}
const myConnectorMappings = await connectorMappingsService.find({
client: savedObjectsClient,
options: {
hasReference: {
type: ACTION_SAVED_OBJECT_TYPE,
id: connectorId,
},
},
});
let theMapping;
// Create connector mappings if there are none
if (myConnectorMappings.total === 0) {
const res = await caseClient.getFields({
actionsClient,
connectorId,
connectorType,
});
theMapping = await connectorMappingsService.post({
client: savedObjectsClient,
attributes: {
mappings: res.defaultMappings,
},
references: [
{
type: ACTION_SAVED_OBJECT_TYPE,
name: `associated-${ACTION_SAVED_OBJECT_TYPE}`,
id: connectorId,
},
],
});
} else {
theMapping = myConnectorMappings.saved_objects[0];
}
return theMapping ? theMapping.attributes.mappings : [];
};

View file

@ -0,0 +1,545 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
JiraGetFieldsResponse,
ResilientGetFieldsResponse,
ServiceNowGetFieldsResponse,
} from '../../../../actions/server/types';
import { formatFields } from './utils';
import { ConnectorTypes } from '../../../common/api/connectors';
const jiraFields: JiraGetFieldsResponse = {
summary: {
required: true,
allowedValues: [],
defaultValue: {},
schema: {
type: 'string',
},
name: 'Summary',
},
issuetype: {
required: true,
allowedValues: [
{
self: 'https://siem-kibana.atlassian.net/rest/api/2/issuetype/10023',
id: '10023',
description: 'A problem or error.',
iconUrl:
'https://siem-kibana.atlassian.net/secure/viewavatar?size=medium&avatarId=10303&avatarType=issuetype',
name: 'Bug',
subtask: false,
avatarId: 10303,
},
],
defaultValue: {},
schema: {
type: 'issuetype',
},
name: 'Issue Type',
},
attachment: {
required: false,
allowedValues: [],
defaultValue: {},
schema: {
type: 'array',
items: 'attachment',
},
name: 'Attachment',
},
duedate: {
required: false,
allowedValues: [],
defaultValue: {},
schema: {
type: 'date',
},
name: 'Due date',
},
description: {
required: false,
allowedValues: [],
defaultValue: {},
schema: {
type: 'string',
},
name: 'Description',
},
project: {
required: true,
allowedValues: [
{
self: 'https://siem-kibana.atlassian.net/rest/api/2/project/10015',
id: '10015',
key: 'RJ2',
name: 'RJ2',
projectTypeKey: 'business',
simplified: false,
avatarUrls: {
'48x48':
'https://siem-kibana.atlassian.net/secure/projectavatar?pid=10015&avatarId=10412',
'24x24':
'https://siem-kibana.atlassian.net/secure/projectavatar?size=small&s=small&pid=10015&avatarId=10412',
'16x16':
'https://siem-kibana.atlassian.net/secure/projectavatar?size=xsmall&s=xsmall&pid=10015&avatarId=10412',
'32x32':
'https://siem-kibana.atlassian.net/secure/projectavatar?size=medium&s=medium&pid=10015&avatarId=10412',
},
},
],
defaultValue: {},
schema: {
type: 'project',
},
name: 'Project',
},
assignee: {
required: false,
allowedValues: [],
defaultValue: {},
schema: {
type: 'user',
},
name: 'Assignee',
},
labels: {
required: false,
allowedValues: [],
defaultValue: {},
schema: {
type: 'array',
items: 'string',
},
name: 'Labels',
},
};
const resilientFields: ResilientGetFieldsResponse = [
{ input_type: 'text', name: 'addr', read_only: false, text: 'Address' },
{
input_type: 'boolean',
name: 'alberta_health_risk_assessment',
read_only: false,
text: 'Alberta Health Risk Assessment',
},
{ input_type: 'number', name: 'hard_liability', read_only: true, text: 'Assessed Liability' },
{ input_type: 'text', name: 'city', read_only: false, text: 'City' },
{ input_type: 'select', name: 'country', read_only: false, text: 'Country/Region' },
{ input_type: 'select_owner', name: 'creator_id', read_only: true, text: 'Created By' },
{ input_type: 'select', name: 'crimestatus_id', read_only: false, text: 'Criminal Activity' },
{ input_type: 'boolean', name: 'data_encrypted', read_only: false, text: 'Data Encrypted' },
{ input_type: 'select', name: 'data_format', read_only: false, text: 'Data Format' },
{ input_type: 'datetimepicker', name: 'end_date', read_only: true, text: 'Date Closed' },
{ input_type: 'datetimepicker', name: 'create_date', read_only: true, text: 'Date Created' },
{
input_type: 'datetimepicker',
name: 'determined_date',
read_only: false,
text: 'Date Determined',
},
{
input_type: 'datetimepicker',
name: 'discovered_date',
read_only: false,
required: 'always',
text: 'Date Discovered',
},
{ input_type: 'datetimepicker', name: 'start_date', read_only: false, text: 'Date Occurred' },
{ input_type: 'select', name: 'exposure_dept_id', read_only: false, text: 'Department' },
{ input_type: 'textarea', name: 'description', read_only: false, text: 'Description' },
{ input_type: 'boolean', name: 'employee_involved', read_only: false, text: 'Employee Involved' },
{ input_type: 'boolean', name: 'data_contained', read_only: false, text: 'Exposure Resolved' },
{ input_type: 'select', name: 'exposure_type_id', read_only: false, text: 'Exposure Type' },
{
input_type: 'multiselect',
name: 'gdpr_breach_circumstances',
read_only: false,
text: 'GDPR Breach Circumstances',
},
{ input_type: 'select', name: 'gdpr_breach_type', read_only: false, text: 'GDPR Breach Type' },
{
input_type: 'textarea',
name: 'gdpr_breach_type_comment',
read_only: false,
text: 'GDPR Breach Type Comment',
},
{ input_type: 'select', name: 'gdpr_consequences', read_only: false, text: 'GDPR Consequences' },
{
input_type: 'textarea',
name: 'gdpr_consequences_comment',
read_only: false,
text: 'GDPR Consequences Comment',
},
{
input_type: 'select',
name: 'gdpr_final_assessment',
read_only: false,
text: 'GDPR Final Assessment',
},
{
input_type: 'textarea',
name: 'gdpr_final_assessment_comment',
read_only: false,
text: 'GDPR Final Assessment Comment',
},
{
input_type: 'select',
name: 'gdpr_identification',
read_only: false,
text: 'GDPR Identification',
},
{
input_type: 'textarea',
name: 'gdpr_identification_comment',
read_only: false,
text: 'GDPR Identification Comment',
},
{
input_type: 'select',
name: 'gdpr_personal_data',
read_only: false,
text: 'GDPR Personal Data',
},
{
input_type: 'textarea',
name: 'gdpr_personal_data_comment',
read_only: false,
text: 'GDPR Personal Data Comment',
},
{
input_type: 'boolean',
name: 'gdpr_subsequent_notification',
read_only: false,
text: 'GDPR Subsequent Notification',
},
{ input_type: 'number', name: 'id', read_only: true, text: 'ID' },
{ input_type: 'boolean', name: 'impact_likely', read_only: false, text: 'Impact Likely' },
{
input_type: 'boolean',
name: 'ny_impact_likely',
read_only: false,
text: 'Impact Likely for New York',
},
{
input_type: 'boolean',
name: 'or_impact_likely',
read_only: false,
text: 'Impact Likely for Oregon',
},
{
input_type: 'boolean',
name: 'wa_impact_likely',
read_only: false,
text: 'Impact Likely for Washington',
},
{ input_type: 'boolean', name: 'confirmed', read_only: false, text: 'Incident Disposition' },
{ input_type: 'multiselect', name: 'incident_type_ids', read_only: false, text: 'Incident Type' },
{
input_type: 'text',
name: 'exposure_individual_name',
read_only: false,
text: 'Individual Name',
},
{
input_type: 'select',
name: 'harmstatus_id',
read_only: false,
text: 'Is harm/risk/misuse foreseeable?',
},
{ input_type: 'text', name: 'jurisdiction_name', read_only: false, text: 'Jurisdiction' },
{
input_type: 'datetimepicker',
name: 'inc_last_modified_date',
read_only: true,
text: 'Last Modified',
},
{
input_type: 'multiselect',
name: 'gdpr_lawful_data_processing_categories',
read_only: false,
text: 'Lawful Data Processing Categories',
},
{ input_type: 'multiselect_members', name: 'members', read_only: false, text: 'Members' },
{ input_type: 'text', name: 'name', read_only: false, required: 'always', text: 'Name' },
{ input_type: 'boolean', name: 'negative_pr_likely', read_only: false, text: 'Negative PR' },
{ input_type: 'datetimepicker', name: 'due_date', read_only: true, text: 'Next Due Date' },
{
input_type: 'multiselect',
name: 'nist_attack_vectors',
read_only: false,
text: 'NIST Attack Vectors',
},
{ input_type: 'select', name: 'org_handle', read_only: true, text: 'Organization' },
{ input_type: 'select_owner', name: 'owner_id', read_only: false, text: 'Owner' },
{ input_type: 'select', name: 'phase_id', read_only: true, text: 'Phase' },
{
input_type: 'select',
name: 'pipeda_other_factors',
read_only: false,
text: 'PIPEDA Other Factors',
},
{
input_type: 'textarea',
name: 'pipeda_other_factors_comment',
read_only: false,
text: 'PIPEDA Other Factors Comment',
},
{
input_type: 'select',
name: 'pipeda_overall_assessment',
read_only: false,
text: 'PIPEDA Overall Assessment',
},
{
input_type: 'textarea',
name: 'pipeda_overall_assessment_comment',
read_only: false,
text: 'PIPEDA Overall Assessment Comment',
},
{
input_type: 'select',
name: 'pipeda_probability_of_misuse',
read_only: false,
text: 'PIPEDA Probability of Misuse',
},
{
input_type: 'textarea',
name: 'pipeda_probability_of_misuse_comment',
read_only: false,
text: 'PIPEDA Probability of Misuse Comment',
},
{
input_type: 'select',
name: 'pipeda_sensitivity_of_pi',
read_only: false,
text: 'PIPEDA Sensitivity of PI',
},
{
input_type: 'textarea',
name: 'pipeda_sensitivity_of_pi_comment',
read_only: false,
text: 'PIPEDA Sensitivity of PI Comment',
},
{ input_type: 'text', name: 'reporter', read_only: false, text: 'Reporting Individual' },
{
input_type: 'select',
name: 'resolution_id',
read_only: false,
required: 'close',
text: 'Resolution',
},
{
input_type: 'textarea',
name: 'resolution_summary',
read_only: false,
required: 'close',
text: 'Resolution Summary',
},
{ input_type: 'select', name: 'gdpr_harm_risk', read_only: false, text: 'Risk of Harm' },
{ input_type: 'select', name: 'severity_code', read_only: false, text: 'Severity' },
{ input_type: 'boolean', name: 'inc_training', read_only: true, text: 'Simulation' },
{ input_type: 'multiselect', name: 'data_source_ids', read_only: false, text: 'Source of Data' },
{ input_type: 'select', name: 'state', read_only: false, text: 'State' },
{ input_type: 'select', name: 'plan_status', read_only: false, text: 'Status' },
{ input_type: 'select', name: 'exposure_vendor_id', read_only: false, text: 'Vendor' },
{
input_type: 'boolean',
name: 'data_compromised',
read_only: false,
text: 'Was personal information or personal data involved?',
},
{
input_type: 'select',
name: 'workspace',
read_only: false,
required: 'always',
text: 'Workspace',
},
{ input_type: 'text', name: 'zip', read_only: false, text: 'Zip' },
];
const serviceNowFields: ServiceNowGetFieldsResponse = [
{
column_label: 'Approval',
mandatory: 'false',
max_length: '40',
element: 'approval',
},
{
column_label: 'Close notes',
mandatory: 'false',
max_length: '4000',
element: 'close_notes',
},
{
column_label: 'Contact type',
mandatory: 'false',
max_length: '40',
element: 'contact_type',
},
{
column_label: 'Correlation display',
mandatory: 'false',
max_length: '100',
element: 'correlation_display',
},
{
column_label: 'Correlation ID',
mandatory: 'false',
max_length: '100',
element: 'correlation_id',
},
{
column_label: 'Description',
mandatory: 'false',
max_length: '4000',
element: 'description',
},
{
column_label: 'Number',
mandatory: 'false',
max_length: '40',
element: 'number',
},
{
column_label: 'Short description',
mandatory: 'false',
max_length: '160',
element: 'short_description',
},
{
column_label: 'Created by',
mandatory: 'false',
max_length: '40',
element: 'sys_created_by',
},
{
column_label: 'Updated by',
mandatory: 'false',
max_length: '40',
element: 'sys_updated_by',
},
{
column_label: 'Upon approval',
mandatory: 'false',
max_length: '40',
element: 'upon_approval',
},
{
column_label: 'Upon reject',
mandatory: 'false',
max_length: '40',
element: 'upon_reject',
},
];
const formatFieldsTestData = [
{
expected: [
{ id: 'summary', name: 'Summary', required: true, type: 'text' },
{ id: 'description', name: 'Description', required: false, type: 'text' },
],
fields: jiraFields,
type: ConnectorTypes.jira,
},
{
expected: [
{ id: 'addr', name: 'Address', required: false, type: 'text' },
{ id: 'city', name: 'City', required: false, type: 'text' },
{ id: 'description', name: 'Description', required: false, type: 'textarea' },
{
id: 'gdpr_breach_type_comment',
name: 'GDPR Breach Type Comment',
required: false,
type: 'textarea',
},
{
id: 'gdpr_consequences_comment',
name: 'GDPR Consequences Comment',
required: false,
type: 'textarea',
},
{
id: 'gdpr_final_assessment_comment',
name: 'GDPR Final Assessment Comment',
required: false,
type: 'textarea',
},
{
id: 'gdpr_identification_comment',
name: 'GDPR Identification Comment',
required: false,
type: 'textarea',
},
{
id: 'gdpr_personal_data_comment',
name: 'GDPR Personal Data Comment',
required: false,
type: 'textarea',
},
{ id: 'exposure_individual_name', name: 'Individual Name', required: false, type: 'text' },
{ id: 'jurisdiction_name', name: 'Jurisdiction', required: false, type: 'text' },
{ id: 'name', name: 'Name', required: true, type: 'text' },
{
id: 'pipeda_other_factors_comment',
name: 'PIPEDA Other Factors Comment',
required: false,
type: 'textarea',
},
{
id: 'pipeda_overall_assessment_comment',
name: 'PIPEDA Overall Assessment Comment',
required: false,
type: 'textarea',
},
{
id: 'pipeda_probability_of_misuse_comment',
name: 'PIPEDA Probability of Misuse Comment',
required: false,
type: 'textarea',
},
{
id: 'pipeda_sensitivity_of_pi_comment',
name: 'PIPEDA Sensitivity of PI Comment',
required: false,
type: 'textarea',
},
{ id: 'reporter', name: 'Reporting Individual', required: false, type: 'text' },
{ id: 'resolution_summary', name: 'Resolution Summary', required: false, type: 'textarea' },
{ id: 'zip', name: 'Zip', required: false, type: 'text' },
],
fields: resilientFields,
type: ConnectorTypes.resilient,
},
{
expected: [
{ id: 'approval', name: 'Approval', required: false, type: 'text' },
{ id: 'close_notes', name: 'Close notes', required: false, type: 'textarea' },
{ id: 'contact_type', name: 'Contact type', required: false, type: 'text' },
{ id: 'correlation_display', name: 'Correlation display', required: false, type: 'text' },
{ id: 'correlation_id', name: 'Correlation ID', required: false, type: 'text' },
{ id: 'description', name: 'Description', required: false, type: 'textarea' },
{ id: 'number', name: 'Number', required: false, type: 'text' },
{ id: 'short_description', name: 'Short description', required: false, type: 'text' },
{ id: 'sys_created_by', name: 'Created by', required: false, type: 'text' },
{ id: 'sys_updated_by', name: 'Updated by', required: false, type: 'text' },
{ id: 'upon_approval', name: 'Upon approval', required: false, type: 'text' },
{ id: 'upon_reject', name: 'Upon reject', required: false, type: 'text' },
],
fields: serviceNowFields,
type: ConnectorTypes.servicenow,
},
];
describe('client/configure/utils', () => {
describe('formatFields', () => {
formatFieldsTestData.forEach(({ expected, fields, type }) => {
it(`normalizes ${type} fields to common type ConnectorField`, () => {
const result = formatFields(fields, type);
expect(result).toEqual(expected);
});
});
});
});

View file

@ -0,0 +1,169 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
ConnectorField,
ConnectorMappingsAttributes,
ConnectorTypes,
} from '../../../common/api/connectors';
import {
JiraGetFieldsResponse,
ResilientGetFieldsResponse,
ServiceNowGetFieldsResponse,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../actions/server/types';
const normalizeJiraFields = (jiraFields: JiraGetFieldsResponse): ConnectorField[] =>
Object.keys(jiraFields).reduce<ConnectorField[]>(
(acc, data) =>
jiraFields[data].schema.type === 'string'
? [
...acc,
{
id: data,
name: jiraFields[data].name,
required: jiraFields[data].required,
type: 'text',
},
]
: acc,
[]
);
const normalizeResilientFields = (resilientFields: ResilientGetFieldsResponse): ConnectorField[] =>
resilientFields.reduce<ConnectorField[]>(
(acc: ConnectorField[], data) =>
(data.input_type === 'textarea' || data.input_type === 'text') && !data.read_only
? [
...acc,
{
id: data.name,
name: data.text,
required: data.required === 'always',
type: data.input_type,
},
]
: acc,
[]
);
const normalizeServiceNowFields = (snFields: ServiceNowGetFieldsResponse): ConnectorField[] =>
snFields.reduce<ConnectorField[]>(
(acc, data) => [
...acc,
{
id: data.element,
name: data.column_label,
required: data.mandatory === 'true',
type: parseFloat(data.max_length) > 160 ? 'textarea' : 'text',
},
],
[]
);
export const formatFields = (theData: unknown, theType: string): ConnectorField[] => {
switch (theType) {
case ConnectorTypes.jira:
return normalizeJiraFields(theData as JiraGetFieldsResponse);
case ConnectorTypes.resilient:
return normalizeResilientFields(theData as ResilientGetFieldsResponse);
case ConnectorTypes.servicenow:
return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse);
default:
return [];
}
};
const findTextField = (fields: ConnectorField[]): string =>
(
fields.find((field: ConnectorField) => field.type === 'text' && field.required) ??
fields.find((field: ConnectorField) => field.type === 'text')
)?.id ?? '';
const findTextAreaField = (fields: ConnectorField[]): string =>
(
fields.find((field: ConnectorField) => field.type === 'textarea' && field.required) ??
fields.find((field: ConnectorField) => field.type === 'textarea') ??
fields.find((field: ConnectorField) => field.type === 'text')
)?.id ?? '';
const getPreferredFields = (theType: string) => {
let title: string = '';
let description: string = '';
if (theType === ConnectorTypes.jira) {
title = 'summary';
description = 'description';
} else if (theType === ConnectorTypes.resilient) {
title = 'name';
description = 'description';
} else if (theType === ConnectorTypes.servicenow) {
title = 'short_description';
description = 'description';
}
return { title, description };
};
const getRemainingFields = (fields: ConnectorField[], titleTarget: string) =>
fields.filter((field: ConnectorField) => field.id !== titleTarget);
const getDynamicFields = (fields: ConnectorField[], dynamicTitle = findTextField(fields)) => {
const remainingFields = getRemainingFields(fields, dynamicTitle);
const dynamicDescription = findTextAreaField(remainingFields);
return {
description: dynamicDescription,
title: dynamicTitle,
};
};
const getField = (fields: ConnectorField[], fieldId: string) =>
fields.find((field: ConnectorField) => field.id === fieldId);
// if dynamic title is not required and preferred is, true
const shouldTargetBePreferred = (
fields: ConnectorField[],
dynamic: string,
preferred: string
): boolean => {
if (dynamic !== preferred) {
const dynamicT = getField(fields, dynamic);
const preferredT = getField(fields, preferred);
return preferredT != null && !(dynamicT?.required && !preferredT.required);
}
return false;
};
export const createDefaultMapping = (
fields: ConnectorField[],
theType: string
): ConnectorMappingsAttributes[] => {
const { description: dynamicDescription, title: dynamicTitle } = getDynamicFields(fields);
const { description: preferredDescription, title: preferredTitle } = getPreferredFields(theType);
let titleTarget = dynamicTitle;
let descriptionTarget = dynamicDescription;
if (preferredTitle.length > 0 && preferredDescription.length > 0) {
if (shouldTargetBePreferred(fields, dynamicTitle, preferredTitle)) {
const { description: dynamicDescriptionOverwrite } = getDynamicFields(fields, preferredTitle);
titleTarget = preferredTitle;
descriptionTarget = dynamicDescriptionOverwrite;
}
if (shouldTargetBePreferred(fields, descriptionTarget, preferredDescription)) {
descriptionTarget = preferredDescription;
}
}
return [
{
source: 'title',
target: titleTarget,
action_type: 'overwrite',
},
{
source: 'description',
target: descriptionTarget,
action_type: 'overwrite',
},
{
source: 'comments',
target: 'comments',
action_type: 'append',
},
];
};

View file

@ -8,6 +8,7 @@ import { KibanaRequest, RequestHandlerContext } from 'kibana/server';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { createCaseClient } from '.';
import {
connectorMappingsServiceMock,
createCaseServiceMock,
createConfigureServiceMock,
createUserActionServiceMock,
@ -24,12 +25,13 @@ jest.mock('./cases/update');
jest.mock('./comments/add');
jest.mock('./alerts/update_status');
const caseService = createCaseServiceMock();
const caseConfigureService = createConfigureServiceMock();
const userActionService = createUserActionServiceMock();
const alertsService = createAlertServiceMock();
const savedObjectsClient = savedObjectsClientMock.create();
const caseService = createCaseServiceMock();
const connectorMappingsService = connectorMappingsServiceMock();
const request = {} as KibanaRequest;
const savedObjectsClient = savedObjectsClientMock.create();
const userActionService = createUserActionServiceMock();
const context = {} as RequestHandlerContext;
const createMock = create as jest.Mock;
@ -40,53 +42,58 @@ const updateAlertsStatusMock = updateAlertsStatus as jest.Mock;
describe('createCaseClient()', () => {
test('it creates the client correctly', async () => {
createCaseClient({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
});
expect(createMock).toHaveBeenCalledWith({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
});
expect(updateMock).toHaveBeenCalledWith({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
});
expect(addCommentMock).toHaveBeenCalledWith({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
});
expect(updateAlertsStatusMock).toHaveBeenCalledWith({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
});
});
});

View file

@ -8,55 +8,73 @@ import { CaseClientFactoryArguments, CaseClient } from './types';
import { create } from './cases/create';
import { update } from './cases/update';
import { addComment } from './comments/add';
import { getFields } from './configure/get_fields';
import { getMappings } from './configure/get_mappings';
import { updateAlertsStatus } from './alerts/update_status';
export { CaseClient } from './types';
export const createCaseClient = ({
savedObjectsClient,
request,
caseConfigureService,
caseService,
connectorMappingsService,
request,
savedObjectsClient,
userActionService,
alertsService,
context,
}: CaseClientFactoryArguments): CaseClient => {
return {
create: create({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
}),
update: update({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
}),
addComment: addComment({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
}),
getFields: getFields(),
getMappings: getMappings({
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
}),
updateAlertsStatus: updateAlertsStatus({
savedObjectsClient,
request,
alertsService,
caseConfigureService,
caseService,
userActionService,
alertsService,
connectorMappingsService,
context,
request,
savedObjectsClient,
userActionService,
}),
};
};

View file

@ -8,10 +8,11 @@ import { KibanaRequest, RequestHandlerContext } from 'kibana/server';
import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
import { actionsClientMock } from '../../../actions/server/mocks';
import {
CaseService,
CaseConfigureService,
CaseUserActionServiceSetup,
AlertService,
CaseConfigureService,
CaseService,
CaseUserActionServiceSetup,
ConnectorMappingsService,
} from '../services';
import { CaseClient } from './types';
import { authenticationMock } from '../routes/api/__fixtures__';
@ -20,9 +21,11 @@ import { getActions } from '../routes/api/__mocks__/request_responses';
export type CaseClientMock = jest.Mocked<CaseClient>;
export const createCaseClientMock = (): CaseClientMock => ({
create: jest.fn(),
update: jest.fn(),
addComment: jest.fn(),
create: jest.fn(),
getFields: jest.fn(),
getMappings: jest.fn(),
update: jest.fn(),
updateAlertsStatus: jest.fn(),
});
@ -41,11 +44,14 @@ export const createCaseClientWithMockSavedObjectsClient = async (
const caseServicePlugin = new CaseService(log);
const caseConfigureServicePlugin = new CaseConfigureService(log);
const connectorMappingsServicePlugin = new ConnectorMappingsService(log);
const caseService = await caseServicePlugin.setup({
authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(),
});
const caseConfigureService = await caseConfigureServicePlugin.setup();
const connectorMappingsService = await connectorMappingsServicePlugin.setup();
const userActionService = {
postUserActions: jest.fn(),
getUserActions: jest.fn(),
@ -75,11 +81,11 @@ export const createCaseClientWithMockSavedObjectsClient = async (
request,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
context,
});
return {
client: caseClient,
services: { userActionService },

View file

@ -5,13 +5,16 @@
*/
import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server';
import { ActionsClient } from '../../../actions/server';
import {
CasePostRequest,
CasesPatchRequest,
CommentRequest,
CaseResponse,
CasesPatchRequest,
CasesResponse,
CaseStatuses,
CommentRequest,
ConnectorMappingsAttributes,
GetFieldsResponse,
} from '../../common/api';
import {
CaseConfigureServiceSetup,
@ -19,7 +22,7 @@ import {
CaseUserActionServiceSetup,
AlertServiceContract,
} from '../services';
import { ConnectorMappingsServiceSetup } from '../services/connector_mappings';
export interface CaseClientCreate {
theCase: CasePostRequest;
}
@ -43,18 +46,33 @@ export interface CaseClientUpdateAlertsStatus {
type PartialExceptFor<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface CaseClientFactoryArguments {
savedObjectsClient: SavedObjectsClientContract;
request: KibanaRequest;
caseConfigureService: CaseConfigureServiceSetup;
caseService: CaseServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
request: KibanaRequest;
savedObjectsClient: SavedObjectsClientContract;
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
context?: PartialExceptFor<RequestHandlerContext, 'core'>;
}
export interface ConfigureFields {
actionsClient: ActionsClient;
connectorId: string;
connectorType: string;
}
export interface CaseClient {
create: (args: CaseClientCreate) => Promise<CaseResponse>;
update: (args: CaseClientUpdate) => Promise<CasesResponse>;
addComment: (args: CaseClientAddComment) => Promise<CaseResponse>;
create: (args: CaseClientCreate) => Promise<CaseResponse>;
getFields: (args: ConfigureFields) => Promise<GetFieldsResponse>;
getMappings: (args: MappingsClient) => Promise<ConnectorMappingsAttributes[]>;
update: (args: CaseClientUpdate) => Promise<CasesResponse>;
updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise<void>;
}
export interface MappingsClient {
actionsClient: ActionsClient;
caseClient: CaseClient;
connectorId: string;
connectorType: string;
}

View file

@ -11,6 +11,7 @@ import { actionsMock } from '../../../../actions/server/mocks';
import { validateParams } from '../../../../actions/server/lib';
import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api';
import {
connectorMappingsServiceMock,
createCaseServiceMock,
createConfigureServiceMock,
createUserActionServiceMock,
@ -35,12 +36,14 @@ describe('case connector', () => {
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const caseService = createCaseServiceMock();
const caseConfigureService = createConfigureServiceMock();
const connectorMappingsService = connectorMappingsServiceMock();
const userActionService = createUserActionServiceMock();
const alertsService = createAlertServiceMock();
caseActionType = getActionType({
logger,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
});

View file

@ -29,6 +29,7 @@ export function getActionType({
logger,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
}: GetActionTypeParams): CaseActionType {
@ -41,11 +42,12 @@ export function getActionType({
params: CaseExecutorParamsSchema,
},
executor: curry(executor)({
logger,
caseService,
caseConfigureService,
userActionService,
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
logger,
userActionService,
}),
};
}
@ -53,11 +55,12 @@ export function getActionType({
// action executor
async function executor(
{
logger,
caseService,
caseConfigureService,
userActionService,
alertsService,
caseConfigureService,
caseService,
connectorMappingsService,
logger,
userActionService,
}: GetActionTypeParams,
execOptions: CaseActionTypeExecutorOptions
): Promise<ActionTypeExecutorResult<CaseExecutorResponse | {}>> {
@ -71,6 +74,7 @@ async function executor(
request: {} as KibanaRequest,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
// TODO: When case connector is enabled we should figure out how to pass the context.

View file

@ -16,6 +16,7 @@ import {
CaseServiceSetup,
CaseConfigureServiceSetup,
CaseUserActionServiceSetup,
ConnectorMappingsServiceSetup,
AlertServiceContract,
} from '../services';
@ -26,6 +27,7 @@ export interface GetActionTypeParams {
logger: Logger;
caseService: CaseServiceSetup;
caseConfigureService: CaseConfigureServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
}
@ -46,6 +48,7 @@ export const registerConnectors = ({
logger,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
}: RegisterConnectorsArgs) => {
@ -54,6 +57,7 @@ export const registerConnectors = ({
logger,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
})

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from '../../../../src/core/server';
import { PluginInitializerContext } from 'kibana/server';
import { ConfigSchema } from './config';
import { CasePlugin } from './plugin';

View file

@ -22,9 +22,10 @@ import { APP_ID } from '../common/constants';
import { ConfigType } from './config';
import { initCaseApi } from './routes/api';
import {
caseSavedObjectType,
caseConfigureSavedObjectType,
caseCommentSavedObjectType,
caseConfigureSavedObjectType,
caseConnectorMappingsSavedObjectType,
caseSavedObjectType,
caseUserActionSavedObjectType,
} from './saved_object_types';
import {
@ -34,6 +35,8 @@ import {
CaseServiceSetup,
CaseUserActionService,
CaseUserActionServiceSetup,
ConnectorMappingsService,
ConnectorMappingsServiceSetup,
AlertService,
AlertServiceContract,
} from './services';
@ -51,8 +54,9 @@ export interface PluginsSetup {
export class CasePlugin {
private readonly log: Logger;
private caseService?: CaseServiceSetup;
private caseConfigureService?: CaseConfigureServiceSetup;
private caseService?: CaseServiceSetup;
private connectorMappingsService?: ConnectorMappingsServiceSetup;
private userActionService?: CaseUserActionServiceSetup;
private alertsService?: AlertService;
@ -67,9 +71,10 @@ export class CasePlugin {
return;
}
core.savedObjects.registerType(caseSavedObjectType);
core.savedObjects.registerType(caseCommentSavedObjectType);
core.savedObjects.registerType(caseConfigureSavedObjectType);
core.savedObjects.registerType(caseConnectorMappingsSavedObjectType);
core.savedObjects.registerType(caseSavedObjectType);
core.savedObjects.registerType(caseUserActionSavedObjectType);
this.log.debug(
@ -82,6 +87,7 @@ export class CasePlugin {
authentication: plugins.security != null ? plugins.security.authc : null,
});
this.caseConfigureService = await new CaseConfigureService(this.log).setup();
this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup();
this.userActionService = await new CaseUserActionService(this.log).setup();
this.alertsService = new AlertService();
@ -91,6 +97,7 @@ export class CasePlugin {
core,
caseService: this.caseService,
caseConfigureService: this.caseConfigureService,
connectorMappingsService: this.connectorMappingsService,
userActionService: this.userActionService,
alertsService: this.alertsService,
})
@ -100,6 +107,7 @@ export class CasePlugin {
initCaseApi({
caseService: this.caseService,
caseConfigureService: this.caseConfigureService,
connectorMappingsService: this.connectorMappingsService,
userActionService: this.userActionService,
router,
});
@ -109,6 +117,7 @@ export class CasePlugin {
logger: this.log,
caseService: this.caseService,
caseConfigureService: this.caseConfigureService,
connectorMappingsService: this.connectorMappingsService,
userActionService: this.userActionService,
alertsService: this.alertsService,
});
@ -127,6 +136,7 @@ export class CasePlugin {
request,
caseService: this.caseService!,
caseConfigureService: this.caseConfigureService!,
connectorMappingsService: this.connectorMappingsService!,
userActionService: this.userActionService!,
alertsService: this.alertsService!,
context,
@ -146,12 +156,14 @@ export class CasePlugin {
core,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
}: {
core: CoreSetup;
caseService: CaseServiceSetup;
caseConfigureService: CaseConfigureServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
userActionService: CaseUserActionServiceSetup;
alertsService: AlertServiceContract;
}): IContextProvider<RequestHandler<unknown, unknown, unknown>, typeof APP_ID> => {
@ -163,6 +175,7 @@ export class CasePlugin {
savedObjectsClient: savedObjects.getScopedClient(request),
caseService,
caseConfigureService,
connectorMappingsService,
userActionService,
alertsService,
request,

View file

@ -15,16 +15,19 @@ import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_CONFIGURE_SAVED_OBJECT,
CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT,
} from '../../../saved_object_types';
export const createMockSavedObjectsRepository = ({
caseSavedObject = [],
caseCommentSavedObject = [],
caseConfigureSavedObject = [],
caseMappingsSavedObject = [],
}: {
caseSavedObject?: any[];
caseCommentSavedObject?: any[];
caseConfigureSavedObject?: any[];
caseMappingsSavedObject?: any[];
}) => {
const mockSavedObjectsClientContract = ({
bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => {
@ -103,6 +106,14 @@ export const createMockSavedObjectsRepository = ({
) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError('Error thrown for testing');
}
if (findArgs.type === CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT && caseMappingsSavedObject[0]) {
return {
page: 1,
per_page: 5,
total: 1,
saved_objects: caseMappingsSavedObject,
};
}
if (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT) {
return {

View file

@ -5,7 +5,7 @@
*/
import { loggingSystemMock, httpServiceMock } from '../../../../../../../src/core/server/mocks';
import { CaseService, CaseConfigureService } from '../../../services';
import { CaseService, CaseConfigureService, ConnectorMappingsService } from '../../../services';
import { authenticationMock } from '../__fixtures__';
import { RouteDeps } from '../types';
@ -21,15 +21,18 @@ export const createRoute = async (
const caseServicePlugin = new CaseService(log);
const caseConfigureServicePlugin = new CaseConfigureService(log);
const connectorMappingsServicePlugin = new ConnectorMappingsService(log);
const caseService = await caseServicePlugin.setup({
authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(),
});
const caseConfigureService = await caseConfigureServicePlugin.setup();
const connectorMappingsService = await connectorMappingsServicePlugin.setup();
api({
caseConfigureService,
caseService,
connectorMappingsService,
router,
userActionService: {
postUserActions: jest.fn(),

View file

@ -6,13 +6,16 @@
import { SavedObject, SavedObjectsFindResponse } from 'kibana/server';
import {
ESCasesConfigureAttributes,
CommentAttributes,
ESCaseAttributes,
ConnectorTypes,
CommentType,
CaseStatuses,
CommentAttributes,
CommentType,
ConnectorMappings,
ConnectorTypes,
ESCaseAttributes,
ESCasesConfigureAttributes,
} from '../../../../common/api';
import { mappings } from '../cases/configure/mock';
import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../../saved_object_types';
export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
{
@ -386,3 +389,23 @@ export const mockCaseConfigureFind: Array<SavedObjectsFindResponse<ESCasesConfig
saved_objects: [{ ...mockCaseConfigure[0], score: 0 }],
},
];
export const mockCaseMappings: Array<SavedObject<ConnectorMappings>> = [
{
type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT,
id: 'mock-mappings-1',
attributes: {
mappings,
},
references: [],
},
];
export const mockCaseMappingsFind: Array<SavedObjectsFindResponse<ConnectorMappings>> = [
{
page: 1,
per_page: 5,
total: mockCaseConfigure.length,
saved_objects: [{ ...mockCaseMappings[0], score: 0 }],
},
];

View file

@ -8,7 +8,12 @@ import { RequestHandlerContext, KibanaRequest } from 'src/core/server';
import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks';
import { actionsClientMock } from '../../../../../actions/server/mocks';
import { createCaseClient } from '../../../client';
import { CaseService, CaseConfigureService, AlertService } from '../../../services';
import {
AlertService,
CaseService,
CaseConfigureService,
ConnectorMappingsService,
} from '../../../services';
import { getActions } from '../__mocks__/request_responses';
import { authenticationMock } from '../__fixtures__';
@ -20,6 +25,7 @@ export const createRouteContext = async (client: any, badAuth = false) => {
const caseServicePlugin = new CaseService(log);
const caseConfigureServicePlugin = new CaseConfigureService(log);
const connectorMappingsServicePlugin = new ConnectorMappingsService(log);
const caseService = await caseServicePlugin.setup({
authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(),
@ -45,11 +51,13 @@ export const createRouteContext = async (client: any, badAuth = false) => {
},
} as unknown) as RequestHandlerContext;
const connectorMappingsService = await connectorMappingsServicePlugin.setup();
const caseClient = createCaseClient({
savedObjectsClient: client,
request: {} as KibanaRequest,
caseService,
caseConfigureService,
connectorMappingsService,
userActionService: {
postUserActions: jest.fn(),
getUserActions: jest.fn(),

View file

@ -40,27 +40,7 @@ export const getActions = (): FindActionResult[] => [
actionTypeId: '.servicenow',
name: 'ServiceNow',
config: {
incidentConfiguration: {
mapping: [
{
source: 'title',
target: 'short_description',
actionType: 'overwrite',
},
{
source: 'description',
target: 'description',
actionType: 'overwrite',
},
{
source: 'comments',
target: 'comments',
actionType: 'append',
},
],
},
apiUrl: 'https://dev102283.service-now.com',
isCaseOwned: true,
},
isPreconfigured: false,
referencedByCount: 0,
@ -70,25 +50,6 @@ export const getActions = (): FindActionResult[] => [
actionTypeId: '.jira',
name: 'Connector without isCaseOwned',
config: {
incidentConfiguration: {
mapping: [
{
source: 'title',
target: 'short_description',
actionType: 'overwrite',
},
{
source: 'description',
target: 'description',
actionType: 'overwrite',
},
{
source: 'comments',
target: 'comments',
actionType: 'append',
},
],
},
apiUrl: 'https://elastic.jira.com',
},
isPreconfigured: false,

View file

@ -11,11 +11,13 @@ import {
createMockSavedObjectsRepository,
createRoute,
createRouteContext,
mockCaseConfigure,
mockCaseMappings,
} from '../../__fixtures__';
import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects';
import { initGetCaseConfigure } from './get_configure';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
import { mappings } from './mock';
describe('GET configuration', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -32,6 +34,7 @@ describe('GET configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -39,6 +42,7 @@ describe('GET configuration', () => {
expect(res.status).toEqual(200);
expect(res.payload).toEqual({
...mockCaseConfigure[0].attributes,
mappings,
version: mockCaseConfigure[0].version,
});
});
@ -52,6 +56,7 @@ describe('GET configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }],
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -71,6 +76,7 @@ describe('GET configuration', () => {
email: 'testemail@elastic.co',
username: 'elastic',
},
mappings,
updated_at: '2020-04-09T09:43:51.778Z',
updated_by: {
full_name: 'elastic',

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CaseConfigureResponseRt } from '../../../../../common/api';
import Boom from '@hapi/boom';
import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common/api';
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
@ -24,6 +25,23 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps
const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0]
?.attributes ?? { connector: null };
let mappings: ConnectorMappingsAttributes[] = [];
if (connector != null) {
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');
}
mappings = await caseClient.getMappings({
actionsClient,
caseClient,
connectorId: connector.id,
connectorType: connector.type,
});
}
return response.ok({
body:
@ -31,6 +49,7 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps
? CaseConfigureResponseRt.encode({
...caseConfigureWithoutConnector,
connector: transformESConnectorToCaseConnector(connector),
mappings,
version: myCaseConfigure.saved_objects[0].version ?? '',
})
: {},

View file

@ -11,11 +11,13 @@ import {
createMockSavedObjectsRepository,
createRoute,
createRouteContext,
mockCaseConfigure,
mockCaseMappings,
} from '../../__fixtures__';
import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects';
import { initCaseConfigureGetActionConnector } from './get_connectors';
import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants';
import { getActions } from '../../__mocks__/request_responses';
describe('GET connectors', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -32,72 +34,16 @@ describe('GET connectors', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(200);
expect(res.payload).toEqual([
{
id: '123',
actionTypeId: '.servicenow',
name: 'ServiceNow',
config: {
incidentConfiguration: {
mapping: [
{
source: 'title',
target: 'short_description',
actionType: 'overwrite',
},
{
source: 'description',
target: 'description',
actionType: 'overwrite',
},
{
source: 'comments',
target: 'comments',
actionType: 'append',
},
],
},
apiUrl: 'https://dev102283.service-now.com',
isCaseOwned: true,
},
isPreconfigured: false,
referencedByCount: 0,
},
{
id: '456',
actionTypeId: '.jira',
name: 'Connector without isCaseOwned',
config: {
incidentConfiguration: {
mapping: [
{
source: 'title',
target: 'short_description',
actionType: 'overwrite',
},
{
source: 'description',
target: 'description',
actionType: 'overwrite',
},
{
source: 'comments',
target: 'comments',
actionType: 'append',
},
],
},
apiUrl: 'https://elastic.jira.com',
},
isPreconfigured: false,
referencedByCount: 0,
},
]);
const expected = getActions();
expected.shift();
expect(res.payload).toEqual(expected);
});
it('it throws an error when actions client is null', async () => {
@ -109,6 +55,7 @@ describe('GET connectors', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);

View file

@ -17,39 +17,16 @@ import {
RESILIENT_ACTION_TYPE_ID,
} from '../../../../../common/constants';
/**
* We need to take into account connectors that have been created within cases and
* they do not have the isCaseOwned field. Checking for the existence of
* the mapping attribute ensures that the connector is indeed a case connector.
* Cases connector should always have a mapping.
*/
interface CaseAction extends FindActionResult {
config?: {
isCaseOwned?: boolean;
incidentConfiguration?: Record<string, unknown>;
};
}
const isCaseOwned = (action: CaseAction): boolean => {
if (
[SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes(
action.actionTypeId
)
) {
if (action.config?.isCaseOwned === true || action.config?.incidentConfiguration?.mapping) {
return true;
}
}
return false;
};
const isConnectorSupported = (action: FindActionResult): boolean =>
[SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes(
action.actionTypeId
);
/*
* Be aware that this api will only return 20 connectors
*/
export function initCaseConfigureGetActionConnector({ caseService, router }: RouteDeps) {
export function initCaseConfigureGetActionConnector({ router }: RouteDeps) {
router.get(
{
path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`,
@ -63,7 +40,7 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou
throw Boom.notFound('Action client have not been found');
}
const results = (await actionsClient.getAll()).filter(isCaseOwned);
const results = (await actionsClient.getAll()).filter(isConnectorSupported);
return response.ok({ body: results });
} catch (error) {
return response.customError(wrapError(error));

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { RouteDeps } from '../../types';
import { escapeHatch, wrapError } from '../../utils';
import { CASE_CONFIGURE_CONNECTOR_DETAILS_URL } from '../../../../../common/constants';
import {
ConnectorRequestParamsRt,
GetFieldsRequestQueryRt,
throwErrors,
} from '../../../../../common/api';
export function initCaseConfigureGetFields({ router }: RouteDeps) {
router.get(
{
path: CASE_CONFIGURE_CONNECTOR_DETAILS_URL,
validate: {
params: escapeHatch,
query: escapeHatch,
},
},
async (context, request, response) => {
try {
if (!context.case) {
throw Boom.badRequest('RouteHandlerContext is not registered for cases');
}
const query = pipe(
GetFieldsRequestQueryRt.decode(request.query),
fold(throwErrors(Boom.badRequest), identity)
);
const params = pipe(
ConnectorRequestParamsRt.decode(request.params),
fold(throwErrors(Boom.badRequest), identity)
);
const caseClient = context.case.getCaseClient();
const connectorType = query.connector_type;
if (connectorType == null) {
throw Boom.illegal('no connectorType value provided');
}
const actionsClient = await context.actions?.getActionsClient();
if (actionsClient == null) {
throw Boom.notFound('Action client have not been found');
}
const res = await caseClient.getFields({
actionsClient,
connectorId: params.connector_id,
connectorType,
});
return response.ok({
body: res.fields,
});
} catch (error) {
return response.customError(wrapError(error));
}
}
);
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
ServiceConnectorCaseParams,
ServiceConnectorCommentParams,
ConnectorMappingsAttributes,
} 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'];
export const params = {
comments: [comment],
description: 'a description',
impact: '3',
savedObjectId: '1231231231232',
severity: '1',
title: 'a title',
urgency: '2',
...entity,
} as ServiceConnectorCaseParams;
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

@ -11,6 +11,7 @@ import {
createMockSavedObjectsRepository,
createRoute,
createRouteContext,
mockCaseMappings,
} from '../../__fixtures__';
import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects';
@ -42,6 +43,7 @@ describe('PATCH configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -75,6 +77,7 @@ describe('PATCH configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -113,6 +116,7 @@ describe('PATCH configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -166,6 +170,7 @@ describe('PATCH configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -193,6 +198,7 @@ describe('PATCH configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);

View file

@ -13,6 +13,7 @@ import {
CasesConfigurePatchRt,
CaseConfigureResponseRt,
throwErrors,
ConnectorMappingsAttributes,
} from '../../../../../common/api';
import { RouteDeps } from '../../types';
import { wrapError, escapeHatch } from '../../utils';
@ -56,6 +57,24 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
const { username, full_name, email } = await caseService.getUser({ request, response });
const updateDate = new Date().toISOString();
let mappings: ConnectorMappingsAttributes[] = [];
if (connector != null) {
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');
}
mappings = await caseClient.getMappings({
actionsClient,
caseClient,
connectorId: connector.id,
connectorType: connector.type,
});
}
const patch = await caseConfigureService.patch({
client,
caseConfigureId: myCaseConfigure.saved_objects[0].id,
@ -68,7 +87,6 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
updated_by: { email, full_name, username },
},
});
return response.ok({
body: CaseConfigureResponseRt.encode({
...myCaseConfigure.saved_objects[0].attributes,
@ -76,6 +94,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
connector: transformESConnectorToCaseConnector(
patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector
),
mappings,
version: patch.version ?? '',
}),
});

View file

@ -11,9 +11,10 @@ import {
createMockSavedObjectsRepository,
createRoute,
createRouteContext,
mockCaseConfigure,
mockCaseMappings,
} from '../../__fixtures__';
import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects';
import { initPostCaseConfigure } from './post_configure';
import { newConfiguration } from '../../__mocks__/request_responses';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
@ -40,6 +41,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -75,6 +77,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -115,6 +118,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -140,6 +144,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -165,6 +170,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -190,6 +196,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -215,6 +222,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -232,6 +240,7 @@ describe('POST configuration', () => {
const savedObjectRepository = createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
});
const context = await createRouteContext(savedObjectRepository);
@ -251,6 +260,7 @@ describe('POST configuration', () => {
const savedObjectRepository = createMockSavedObjectsRepository({
caseConfigureSavedObject: [],
caseMappingsSavedObject: mockCaseMappings,
});
const context = await createRouteContext(savedObjectRepository);
@ -273,6 +283,7 @@ describe('POST configuration', () => {
mockCaseConfigure[0],
{ ...mockCaseConfigure[0], id: 'mock-configuration-2' },
],
caseMappingsSavedObject: mockCaseMappings,
});
const context = await createRouteContext(savedObjectRepository);
@ -337,6 +348,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -363,6 +375,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -388,6 +401,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);
@ -409,6 +423,7 @@ describe('POST configuration', () => {
const context = await createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
caseMappingsSavedObject: mockCaseMappings,
})
);

View file

@ -32,6 +32,14 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
},
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 client = context.core.savedObjects.client;
const query = pipe(
CasesConfigureRequestRt.decode(request.body),
@ -39,7 +47,6 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
);
const myCaseConfigure = await caseConfigureService.find({ client });
if (myCaseConfigure.saved_objects.length > 0) {
await Promise.all(
myCaseConfigure.saved_objects.map((cc) =>
@ -51,6 +58,12 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
const { email, full_name, username } = await caseService.getUser({ request, response });
const creationDate = new Date().toISOString();
const mappings = await caseClient.getMappings({
actionsClient,
caseClient,
connectorId: query.connector.id,
connectorType: query.connector.type,
});
const post = await caseConfigureService.post({
client,
attributes: {
@ -68,6 +81,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
...post.attributes,
// Reserve for future implementations
connector: transformESConnectorToCaseConnector(post.attributes.connector),
mappings,
version: post.version ?? '',
}),
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
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, connectorMappingsService }: 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

@ -0,0 +1,385 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
prepareFieldsForTransformation,
transformFields,
transformComments,
transformers,
} from './utils';
import { comment as commentObj, defaultPipes, mappings, params, updateUser } from './mock';
import {
ServiceConnectorCaseParams,
ExternalServiceParams,
Incident,
} from '../../../../../common/api/connectors';
const formatComment = { commentId: commentObj.commentId, comment: commentObj.comment };
describe('api/cases/configure/utils', () => {
describe('prepareFieldsForTransformation', () => {
test('prepare fields with defaults', () => {
const res = prepareFieldsForTransformation({
defaultPipes,
params,
mappings,
});
expect(res).toEqual([
{
actionType: 'overwrite',
key: 'short_description',
pipes: ['informationCreated'],
value: 'a title',
},
{
actionType: 'append',
key: 'description',
pipes: ['informationCreated', 'append'],
value: 'a description',
},
]);
});
test('prepare fields with default pipes', () => {
const res = prepareFieldsForTransformation({
defaultPipes: ['myTestPipe'],
mappings,
params,
});
expect(res).toEqual([
{
actionType: 'overwrite',
key: 'short_description',
pipes: ['myTestPipe'],
value: 'a title',
},
{
actionType: 'append',
key: 'description',
pipes: ['myTestPipe', 'append'],
value: 'a description',
},
]);
});
});
describe('transformFields', () => {
test('transform fields for creation correctly', () => {
const fields = prepareFieldsForTransformation({
defaultPipes,
mappings,
params,
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params,
fields,
});
expect(res).toEqual({
short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
});
});
test('transform fields for update correctly', () => {
const fields = prepareFieldsForTransformation({
params,
mappings,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params: {
...params,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: {
username: 'anotherUser',
fullName: 'Another User',
},
},
fields,
currentIncident: {
short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)',
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:
'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)',
});
});
test('add newline character to description', () => {
const fields = prepareFieldsForTransformation({
params,
mappings,
defaultPipes: ['informationUpdated'],
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params,
fields,
currentIncident: {
short_description: 'first title',
description: 'first description',
},
});
expect(res.description?.includes('\r\n')).toBe(true);
});
test('append username if fullname is undefined when create', () => {
const fields = prepareFieldsForTransformation({
defaultPipes,
mappings,
params,
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params: {
...params,
createdBy: { fullName: '', username: 'elastic' },
},
fields,
});
expect(res).toEqual({
short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)',
description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)',
});
});
test('append username if fullname is undefined when update', () => {
const fields = prepareFieldsForTransformation({
defaultPipes: ['informationUpdated'],
mappings,
params,
});
const res = transformFields<ServiceConnectorCaseParams, ExternalServiceParams, Incident>({
params: {
...params,
updatedAt: '2020-03-15T08:34:53.450Z',
updatedBy: { username: 'anotherUser', fullName: '' },
},
fields,
});
expect(res).toEqual({
short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)',
description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)',
});
});
});
describe('transformComments', () => {
test('transform creation comments', () => {
const comments = [commentObj];
const res = transformComments(comments, ['informationCreated']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (created at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`,
},
]);
});
test('transform update comments', () => {
const comments = [
{
...commentObj,
...updateUser,
},
];
const res = transformComments(comments, ['informationUpdated']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (updated at ${updateUser.updatedAt} by ${updateUser.updatedBy.fullName})`,
},
]);
});
test('transform added comments', () => {
const comments = [commentObj];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`,
},
]);
});
test('transform comments without fullname', () => {
const comments = [{ ...commentObj, createdBy: { username: commentObj.createdBy.username } }];
// @ts-ignore testing no fullName
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.username})`,
},
]);
});
test('adds update user correctly', () => {
const comments = [
{
...commentObj,
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: 'Elastic2', username: 'elastic' },
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.fullName})`,
},
]);
});
test('adds update user with empty fullname correctly', () => {
const comments = [
{
...commentObj,
updatedAt: '2020-04-13T08:34:53.450Z',
updatedBy: { fullName: '', username: 'elastic2' },
},
];
const res = transformComments(comments, ['informationAdded']);
expect(res).toEqual([
{
...formatComment,
comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.username})`,
},
]);
});
});
describe('transformers', () => {
const { informationCreated, informationUpdated, informationAdded, append } = transformers;
describe('informationCreated', () => {
test('transforms correctly', () => {
const res = informationCreated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
});
expect(res).toEqual({ value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)' });
});
test('transforms correctly without optional fields', () => {
const res = informationCreated({
value: 'a value',
});
expect(res).toEqual({ value: 'a value (created at by )' });
});
test('returns correctly rest fields', () => {
const res = informationCreated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)',
previousValue: 'previous value',
});
});
});
describe('informationUpdated', () => {
test('transforms correctly', () => {
const res = informationUpdated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
});
expect(res).toEqual({ value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)' });
});
test('transforms correctly without optional fields', () => {
const res = informationUpdated({
value: 'a value',
});
expect(res).toEqual({ value: 'a value (updated at by )' });
});
test('returns correctly rest fields', () => {
const res = informationUpdated({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)',
previousValue: 'previous value',
});
});
});
describe('informationAdded', () => {
test('transforms correctly', () => {
const res = informationAdded({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
});
expect(res).toEqual({ value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)' });
});
test('transforms correctly without optional fields', () => {
const res = informationAdded({
value: 'a value',
});
expect(res).toEqual({ value: 'a value (added at by )' });
});
test('returns correctly rest fields', () => {
const res = informationAdded({
value: 'a value',
date: '2020-04-15T08:19:27.400Z',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)',
previousValue: 'previous value',
});
});
});
describe('append', () => {
test('transforms correctly', () => {
const res = append({
value: 'a value',
previousValue: 'previous value',
});
expect(res).toEqual({ value: 'previous value \r\na value' });
});
test('transforms correctly without optional fields', () => {
const res = append({
value: 'a value',
});
expect(res).toEqual({ value: 'a value' });
});
test('returns correctly rest fields', () => {
const res = append({
value: 'a value',
user: 'elastic',
previousValue: 'previous value',
});
expect(res).toEqual({
value: 'previous value \r\na value',
user: 'elastic',
});
});
});
});
});

View file

@ -0,0 +1,237 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { flow } from 'lodash';
import {
ServiceConnectorCaseParams,
ServiceConnectorCommentParams,
ConnectorMappingsAttributes,
ConnectorTypes,
EntityInformation,
ExternalServiceParams,
Incident,
JiraPushToServiceApiParams,
MapIncident,
PipedField,
PrepareFieldsForTransformArgs,
PushToServiceApiParams,
ResilientPushToServiceApiParams,
ServiceNowPushToServiceApiParams,
SimpleComment,
Transformer,
TransformerArgs,
TransformFieldsArgs,
} from '../../../../../common/api';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionsClient } from '../../../../../../actions/server/actions_client';
export const mapIncident = async (
actionsClient: ActionsClient,
connectorId: string,
connectorType: string,
mappings: ConnectorMappingsAttributes[],
params: ServiceConnectorCaseParams
): Promise<MapIncident> => {
const { comments: caseComments, externalId } = params;
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;
if (externalId) {
try {
currentIncident = ((await actionsClient.execute({
actionId: connectorId,
params: {
subAction: 'getIncident',
subActionParams: { externalId },
},
})) as unknown) as ExternalServiceParams | undefined;
} catch (ex) {
throw new Error(
`Retrieving Incident by id ${externalId} from ${thirdPartyName} failed with exception: ${ex}`
);
}
}
const fields = prepareFieldsForTransformation({
defaultPipes,
mappings,
params,
});
const transformedFields = transformFields<
ServiceConnectorCaseParams,
ExternalServiceParams,
Incident
>({
params,
fields,
currentIncident,
});
incident = { ...incident, ...transformedFields, externalId };
let comments: SimpleComment[] = [];
if (caseComments && Array.isArray(caseComments) && caseComments.length > 0) {
const commentsMapping = mappings.find((m) => m.source === 'comments');
if (commentsMapping?.action_type !== 'nothing') {
comments = transformComments(caseComments, ['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 ServiceNowPushToServiceApiParams['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.username
: entity.createdBy != null
? entity.createdBy.fullName
? entity.createdBy.fullName
: entity.createdBy.username
: '') ?? '';
export const FIELD_INFORMATION = (
mode: string,
date: string | undefined,
user: string | undefined
) => {
switch (mode) {
case 'create':
return i18n.translate('xpack.case.connectors.case.externalIncidentCreated', {
values: { date, user },
defaultMessage: '(created at {date} by {user})',
});
case 'update':
return i18n.translate('xpack.case.connectors.case.externalIncidentUpdated', {
values: { date, user },
defaultMessage: '(updated at {date} by {user})',
});
case 'add':
return i18n.translate('xpack.case.connectors.case.externalIncidentAdded', {
values: { date, user },
defaultMessage: '(added at {date} by {user})',
});
default:
return i18n.translate('xpack.case.connectors.case.externalIncidentDefault', {
values: { date, user },
defaultMessage: '(created at {date} by {user})',
});
}
};
export const transformers: Record<string, Transformer> = {
informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${FIELD_INFORMATION('create', date, user)}`,
...rest,
}),
informationUpdated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${FIELD_INFORMATION('update', date, user)}`,
...rest,
}),
informationAdded: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({
value: `${value} ${FIELD_INFORMATION('add', date, user)}`,
...rest,
}),
append: ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({
value: previousValue ? `${previousValue} \r\n${value}` : `${value}`,
...rest,
}),
};
export const prepareFieldsForTransformation = ({
defaultPipes,
mappings,
params,
}: PrepareFieldsForTransformArgs): PipedField[] =>
mappings.reduce(
(acc: PipedField[], mapping) =>
mapping != null &&
mapping.target !== 'not_mapped' &&
mapping.action_type !== 'nothing' &&
mapping.source !== 'comments'
? [
...acc,
{
key: mapping.target,
value: params[mapping.source] ?? '',
actionType: mapping.action_type,
pipes: mapping.action_type === 'append' ? [...defaultPipes, 'append'] : defaultPipes,
},
]
: acc,
[]
);
export const transformFields = <
P extends EntityInformation,
S extends Record<string, unknown>,
R extends {}
>({
params,
fields,
currentIncident,
}: TransformFieldsArgs<P, S>): R => {
return fields.reduce((prev, cur) => {
const transform = flow(...cur.pipes.map((p) => transformers[p]));
return {
...prev,
[cur.key]: transform({
value: cur.value,
date: params.updatedAt ?? params.createdAt,
user: getEntity(params),
previousValue: currentIncident ? currentIncident[cur.key] : '',
}).value,
};
}, {} as R);
};
export const transformComments = (
comments: ServiceConnectorCommentParams[],
pipes: string[]
): SimpleComment[] =>
comments.map((c) => ({
comment: flow(...pipes.map((p) => transformers[p]))({
value: c.comment,
date: c.updatedAt ?? c.createdAt,
user: getEntity(c),
}).value,
commentId: c.commentId,
}));

View file

@ -25,8 +25,10 @@ import { initPostCommentApi } from './cases/comments/post_comment';
import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors';
import { initGetCaseConfigure } from './cases/configure/get_configure';
import { initCaseConfigureGetFields } from './cases/configure/get_fields';
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';
@ -52,6 +54,8 @@ export function initCaseApi(deps: RouteDeps) {
initGetCaseConfigure(deps);
initPatchCaseConfigure(deps);
initPostCaseConfigure(deps);
initCaseConfigureGetFields(deps);
initPostPushToService(deps);
// Reporters
initGetReportersApi(deps);
// Status

View file

@ -9,13 +9,15 @@ import {
CaseConfigureServiceSetup,
CaseServiceSetup,
CaseUserActionServiceSetup,
ConnectorMappingsServiceSetup,
} from '../../services';
export interface RouteDeps {
caseConfigureService: CaseConfigureServiceSetup;
caseService: CaseServiceSetup;
userActionService: CaseUserActionServiceSetup;
connectorMappingsService: ConnectorMappingsServiceSetup;
router: IRouter;
userActionService: CaseUserActionServiceSetup;
}
export enum SortFieldCase {

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsType } from 'src/core/server';
export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings';
export const caseConnectorMappingsSavedObjectType: SavedObjectsType = {
name: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT,
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
mappings: {
properties: {
source: {
type: 'keyword',
},
target: {
type: 'keyword',
},
action_type: {
type: 'keyword',
},
},
},
},
},
};

View file

@ -8,3 +8,7 @@ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases';
export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure';
export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments';
export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions';
export {
caseConnectorMappingsSavedObjectType,
CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT,
} from './connector_mappings';

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
Logger,
SavedObject,
SavedObjectReference,
SavedObjectsClientContract,
SavedObjectsFindResponse,
} from 'kibana/server';
import { ConnectorMappings, SavedObjectFindOptions } from '../../../common/api';
import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../saved_object_types';
interface ClientArgs {
client: SavedObjectsClientContract;
}
interface FindConnectorMappingsArgs extends ClientArgs {
options?: SavedObjectFindOptions;
}
interface PostConnectorMappingsArgs extends ClientArgs {
attributes: ConnectorMappings;
references: SavedObjectReference[];
}
export interface ConnectorMappingsServiceSetup {
find(args: FindConnectorMappingsArgs): Promise<SavedObjectsFindResponse<ConnectorMappings>>;
post(args: PostConnectorMappingsArgs): Promise<SavedObject<ConnectorMappings>>;
}
export class ConnectorMappingsService {
constructor(private readonly log: Logger) {}
public setup = async (): Promise<ConnectorMappingsServiceSetup> => ({
find: async ({ client, options }: FindConnectorMappingsArgs) => {
try {
this.log.debug(`Attempting to find all connector mappings`);
return await client.find({ ...options, type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT });
} catch (error) {
this.log.debug(`Attempting to find all connector mappings`);
throw error;
}
},
post: async ({ client, attributes, references }: PostConnectorMappingsArgs) => {
try {
this.log.debug(`Attempting to POST a new connector mappings`);
return await client.create(CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, attributes, {
references,
});
} catch (error) {
this.log.debug(`Error on POST a new connector mappings: ${error}`);
throw error;
}
},
});
}

View file

@ -31,6 +31,7 @@ import { readTags } from './tags/read_tags';
export { CaseConfigureService, CaseConfigureServiceSetup } from './configure';
export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions';
export { ConnectorMappingsService, ConnectorMappingsServiceSetup } from './connector_mappings';
export { AlertService, AlertServiceContract } from './alerts';
export interface ClientArgs {

View file

@ -5,14 +5,16 @@
*/
import {
AlertServiceContract,
CaseConfigureServiceSetup,
CaseServiceSetup,
CaseUserActionServiceSetup,
AlertServiceContract,
ConnectorMappingsServiceSetup,
} from '.';
export type CaseServiceMock = jest.Mocked<CaseServiceSetup>;
export type CaseConfigureServiceMock = jest.Mocked<CaseConfigureServiceSetup>;
export type ConnectorMappingsServiceMock = jest.Mocked<ConnectorMappingsServiceSetup>;
export type CaseUserActionServiceMock = jest.Mocked<CaseUserActionServiceSetup>;
export type AlertServiceMock = jest.Mocked<AlertServiceContract>;
@ -43,6 +45,11 @@ export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({
post: jest.fn(),
});
export const connectorMappingsServiceMock = (): ConnectorMappingsServiceMock => ({
find: jest.fn(),
post: jest.fn(),
});
export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({
getUserActions: jest.fn(),
postUserActions: jest.fn(),

View file

@ -5,7 +5,7 @@
*/
import { serviceNowConnector } from '../objects/case';
import { TOASTER } from '../screens/configure_cases';
import { SERVICE_NOW_MAPPING, TOASTER } from '../screens/configure_cases';
import { goToEditExternalConnection } from '../tasks/all_cases';
import {
@ -18,9 +18,33 @@ import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
import { CASES_URL } from '../urls/navigation';
describe('Cases connectors', () => {
const configureResult = {
connector: {
id: 'e271c3b8-f702-4fbc-98e0-db942b573bbd',
name: 'SN',
type: '.servicenow',
fields: null,
},
closure_type: 'close-by-user',
created_at: '2020-12-01T16:28:09.219Z',
created_by: { email: null, full_name: null, username: 'elastic' },
updated_at: null,
updated_by: null,
mappings: [
{ source: 'title', target: 'short_description', action_type: 'overwrite' },
{ source: 'description', target: 'description', action_type: 'overwrite' },
{ source: 'comments', target: 'comments', action_type: 'append' },
],
version: 'WzEwNCwxXQ==',
};
before(() => {
cy.intercept('POST', '/api/actions/action').as('createConnector');
cy.intercept('POST', '/api/cases/configure').as('saveConnector');
cy.intercept('POST', '/api/cases/configure', (req) => {
const connector = req.body.connector;
req.reply((res) => {
res.send(200, { ...configureResult, connector });
});
}).as('saveConnector');
});
it('Configures a new connector', () => {
@ -37,6 +61,7 @@ describe('Cases connectors', () => {
selectLastConnectorCreated(response!.body.id);
cy.wait('@saveConnector', { timeout: 10000 }).its('response.statusCode').should('eql', 200);
cy.get(SERVICE_NOW_MAPPING).first().should('have.text', 'short_description');
cy.get(TOASTER).should('have.text', 'Saved external connection settings');
});
});

View file

@ -83,14 +83,6 @@ export const mockConnectorsResponse = [
actionTypeId: '.jira',
name: 'Jira',
config: {
incidentConfiguration: {
mapping: [
{ source: 'title', target: 'summary', actionType: 'overwrite' },
{ source: 'description', target: 'description', actionType: 'overwrite' },
{ source: 'comments', target: 'comments', actionType: 'append' },
],
},
isCaseOwned: true,
apiUrl: 'https://siem-kibana.atlassian.net',
projectKey: 'RJ',
},
@ -102,14 +94,6 @@ export const mockConnectorsResponse = [
actionTypeId: '.resilient',
name: 'Resilient',
config: {
incidentConfiguration: {
mapping: [
{ source: 'title', target: 'name', actionType: 'overwrite' },
{ source: 'description', target: 'description', actionType: 'overwrite' },
{ source: 'comments', target: 'comments', actionType: 'append' },
],
},
isCaseOwned: true,
apiUrl: 'https://ibm-resilient.siem.estc.dev',
orgId: '201',
},
@ -121,14 +105,6 @@ export const mockConnectorsResponse = [
actionTypeId: '.servicenow',
name: 'ServiceNow',
config: {
incidentConfiguration: {
mapping: [
{ source: 'title', target: 'short_description', actionType: 'overwrite' },
{ source: 'description', target: 'description', actionType: 'overwrite' },
{ source: 'comments', target: 'comments', actionType: 'append' },
],
},
isCaseOwned: true,
apiUrl: 'https://dev65287.service-now.com',
},
isPreconfigured: false,

View file

@ -28,3 +28,5 @@ export const TOASTER = '[data-test-subj="euiToastHeader"]';
export const URL = '[data-test-subj="apiUrlFromInput"]';
export const USERNAME = '[data-test-subj="connector-servicenow-username-form-input"]';
export const SERVICE_NOW_MAPPING = 'code[data-test-subj="field-mapping-target"]';

View file

@ -8,9 +8,8 @@ import { ActionConnector } from '../../../containers/configure/types';
import { UseConnectorsResponse } from '../../../containers/configure/use_connectors';
import { connectorsMock } from '../../../containers/configure/mock';
import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure';
export { mapping } from '../../../containers/configure/mock';
import { ConnectorTypes } from '../../../../../../case/common/api';
export { mappings } from '../../../containers/configure/mock';
export const connectors: ActionConnector[] = connectorsMock;
// x - pack / plugins / triggers_actions_ui;
@ -36,14 +35,14 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = {
},
firstLoad: false,
loading: false,
mapping: null,
mappings: [],
persistCaseConfigure: jest.fn(),
persistLoading: false,
refetchCaseConfigure: jest.fn(),
setClosureType: jest.fn(),
setConnector: jest.fn(),
setCurrentConfiguration: jest.fn(),
setMapping: jest.fn(),
setMappings: jest.fn(),
version: '',
};

View file

@ -11,6 +11,7 @@ import { Connectors, Props } from './connectors';
import { TestProviders } from '../../../common/mock';
import { ConnectorsDropdown } from './connectors_dropdown';
import { connectors } from './__mock__';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
describe('Connectors', () => {
let wrapper: ReactWrapper;
@ -18,13 +19,14 @@ describe('Connectors', () => {
const handleShowEditFlyout = jest.fn();
const props: Props = {
disabled: false,
updateConnectorDisabled: false,
connectors,
selectedConnector: 'none',
isLoading: false,
onChangeConnector,
disabled: false,
handleShowEditFlyout,
isLoading: false,
mappings: [],
onChangeConnector,
selectedConnector: { id: 'none', type: ConnectorTypes.none },
updateConnectorDisabled: false,
};
beforeAll(() => {
@ -66,9 +68,15 @@ describe('Connectors', () => {
test('the connector is changed successfully to none', () => {
onChangeConnector.mockClear();
const newWrapper = mount(<Connectors {...props} selectedConnector={'servicenow-1'} />, {
wrappingComponent: TestProviders,
});
const newWrapper = mount(
<Connectors
{...props}
selectedConnector={{ id: 'servicenow-1', type: ConnectorTypes.servicenow }}
/>,
{
wrappingComponent: TestProviders,
}
);
newWrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
newWrapper.find('button[data-test-subj="dropdown-connector-no-connector"]').simulate('click');
@ -87,9 +95,15 @@ describe('Connectors', () => {
});
test('the text of the update button is shown correctly', () => {
const newWrapper = mount(<Connectors {...props} selectedConnector={'servicenow-1'} />, {
wrappingComponent: TestProviders,
});
const newWrapper = mount(
<Connectors
{...props}
selectedConnector={{ id: 'servicenow-1', type: ConnectorTypes.servicenow }}
/>,
{
wrappingComponent: TestProviders,
}
);
expect(
newWrapper

View file

@ -18,7 +18,9 @@ import styled from 'styled-components';
import { ConnectorsDropdown } from './connectors_dropdown';
import * as i18n from './translations';
import { ActionConnector } from '../../containers/configure/types';
import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types';
import { Mapping } from './mapping';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
const EuiFormRowExtended = styled(EuiFormRow)`
.euiFormRow__labelWrapper {
@ -31,24 +33,26 @@ const EuiFormRowExtended = styled(EuiFormRow)`
export interface Props {
connectors: ActionConnector[];
disabled: boolean;
isLoading: boolean;
updateConnectorDisabled: boolean;
onChangeConnector: (id: string) => void;
selectedConnector: string;
handleShowEditFlyout: () => void;
isLoading: boolean;
mappings: CaseConnectorMapping[];
onChangeConnector: (id: string) => void;
selectedConnector: { id: string; type: string };
updateConnectorDisabled: boolean;
}
const ConnectorsComponent: React.FC<Props> = ({
connectors,
isLoading,
disabled,
updateConnectorDisabled,
handleShowEditFlyout,
isLoading,
mappings,
onChangeConnector,
selectedConnector,
handleShowEditFlyout,
updateConnectorDisabled,
}) => {
const connectorsName = useMemo(
() => connectors.find((c) => c.id === selectedConnector)?.name ?? 'none',
[connectors, selectedConnector]
() => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none',
[connectors, selectedConnector.id]
);
const dropDownLabel = useMemo(
@ -68,10 +72,8 @@ const ConnectorsComponent: React.FC<Props> = ({
</EuiFlexItem>
</EuiFlexGroup>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[connectorsName, updateConnectorDisabled]
[connectorsName, handleShowEditFlyout, updateConnectorDisabled]
);
return (
<>
<EuiDescribedFormGroup
@ -85,15 +87,28 @@ const ConnectorsComponent: React.FC<Props> = ({
label={dropDownLabel}
data-test-subj="case-connectors-form-row"
>
<ConnectorsDropdown
connectors={connectors}
disabled={disabled}
selectedConnector={selectedConnector}
isLoading={isLoading}
onChange={onChangeConnector}
data-test-subj="case-connectors-dropdown"
appendAddConnectorButton={true}
/>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<ConnectorsDropdown
connectors={connectors}
disabled={disabled}
selectedConnector={selectedConnector.id}
isLoading={isLoading}
onChange={onChangeConnector}
data-test-subj="case-connectors-dropdown"
appendAddConnectorButton={true}
/>
</EuiFlexItem>
{selectedConnector.type !== ConnectorTypes.none ? (
<EuiFlexItem grow={false}>
<Mapping
connectorActionTypeId={selectedConnector.type}
isLoading={isLoading}
mappings={mappings}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFormRowExtended>
</EuiDescribedFormGroup>
</>

View file

@ -7,77 +7,48 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { connectorsConfiguration, createDefaultMapping } from '../connectors';
import { FieldMapping, FieldMappingProps } from './field_mapping';
import { mapping } from './__mock__';
import { FieldMappingRow } from './field_mapping_row';
import { mappings } from './__mock__';
import { TestProviders } from '../../../common/mock';
import { FieldMappingRowStatic } from './field_mapping_row_static';
describe('FieldMappingRow', () => {
let wrapper: ReactWrapper;
const onChangeMapping = jest.fn();
const props: FieldMappingProps = {
disabled: false,
mapping,
onChangeMapping,
isLoading: false,
mappings,
connectorActionTypeId: '.servicenow',
};
beforeAll(() => {
wrapper = mount(<FieldMapping {...props} />, { wrappingComponent: TestProviders });
});
test('it renders', () => {
expect(
wrapper.find('[data-test-subj="case-configure-field-mapping-cols"]').first().exists()
wrapper.find('[data-test-subj="case-configure-field-mappings-row-wrapper"]').first().exists()
).toBe(true);
expect(
wrapper.find('[data-test-subj="case-configure-field-mapping-row-wrapper"]').first().exists()
).toBe(true);
expect(wrapper.find(FieldMappingRow).length).toEqual(3);
expect(wrapper.find(FieldMappingRowStatic).length).toEqual(3);
});
test('it shows the correct number of FieldMappingRow with default mapping', () => {
const newWrapper = mount(<FieldMapping {...props} mapping={null} />, {
test('it does not render without mappings', () => {
const newWrapper = mount(<FieldMapping {...props} mappings={[]} />, {
wrappingComponent: TestProviders,
});
expect(newWrapper.find(FieldMappingRow).length).toEqual(3);
expect(
newWrapper
.find('[data-test-subj="case-configure-field-mappings-row-wrapper"]')
.first()
.exists()
).toBe(false);
});
test('it pass the corrects props to mapping row', () => {
const rows = wrapper.find(FieldMappingRow);
const rows = wrapper.find(FieldMappingRowStatic);
rows.forEach((row, index) => {
expect(row.prop('securitySolutionField')).toEqual(mapping[index].source);
expect(row.prop('selectedActionType')).toEqual(mapping[index].actionType);
expect(row.prop('selectedThirdParty')).toEqual(mapping[index].target);
expect(row.prop('securitySolutionField')).toEqual(mappings[index].source);
expect(row.prop('selectedActionType')).toEqual(mappings[index].actionType);
expect(row.prop('selectedThirdParty')).toEqual(mappings[index].target);
});
});
test('it pass the default mapping when mapping is null', () => {
const newWrapper = mount(<FieldMapping {...props} mapping={null} />, {
wrappingComponent: TestProviders,
});
const selectedConnector = connectorsConfiguration['.servicenow'];
const defaultMapping = createDefaultMapping(selectedConnector.fields);
const rows = newWrapper.find(FieldMappingRow);
rows.forEach((row, index) => {
expect(row.prop('securitySolutionField')).toEqual(defaultMapping[index].source);
expect(row.prop('selectedActionType')).toEqual(defaultMapping[index].actionType);
expect(row.prop('selectedThirdParty')).toEqual(defaultMapping[index].target);
});
});
test('it should show zero rows on empty array', () => {
const newWrapper = mount(<FieldMapping {...props} mapping={[]} />, {
wrappingComponent: TestProviders,
});
expect(newWrapper.find(FieldMappingRow).length).toEqual(0);
});
});

View file

@ -4,148 +4,69 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useMemo } from 'react';
import { EuiFormRow, EuiFlexItem, EuiFlexGroup, EuiSuperSelectOption } from '@elastic/eui';
import React, { useMemo } from 'react';
import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import styled from 'styled-components';
import {
CasesConfigurationMapping,
CaseField,
ActionType,
ThirdPartyField,
} from '../../containers/configure/types';
import {
ThirdPartyField as ConnectorConfigurationThirdPartyField,
AllThirdPartyFields,
createDefaultMapping,
connectorsConfiguration,
} from '../connectors';
import { FieldMappingRow } from './field_mapping_row';
import { FieldMappingRowStatic } from './field_mapping_row_static';
import * as i18n from './translations';
import { setActionTypeToMapping, setThirdPartyToMapping } from './utils';
import { CaseConnectorMapping } from '../../containers/configure/types';
import { connectorsConfiguration } from '../connectors';
const FieldRowWrapper = styled.div`
margin-top: 8px;
margin: 10px 0;
font-size: 14px;
`;
const actionTypeOptions: Array<EuiSuperSelectOption<ActionType>> = [
{
value: 'nothing',
inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_NOTHING}</>,
'data-test-subj': 'edit-update-option-nothing',
},
{
value: 'overwrite',
inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_OVERWRITE}</>,
'data-test-subj': 'edit-update-option-overwrite',
},
{
value: 'append',
inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_APPEND}</>,
'data-test-subj': 'edit-update-option-append',
},
];
const getThirdPartyOptions = (
caseField: CaseField,
thirdPartyFields: Record<string, ConnectorConfigurationThirdPartyField>
): Array<EuiSuperSelectOption<AllThirdPartyFields>> =>
(Object.keys(thirdPartyFields) as AllThirdPartyFields[]).reduce<
Array<EuiSuperSelectOption<AllThirdPartyFields>>
>(
(acc, key) => {
if (thirdPartyFields[key].validSourceFields.includes(caseField)) {
return [
...acc,
{
value: key,
inputDisplay: <span>{thirdPartyFields[key].label}</span>,
'data-test-subj': `dropdown-mapping-${key}`,
},
];
}
return acc;
},
[
{
value: 'not_mapped',
inputDisplay: i18n.MAPPING_FIELD_NOT_MAPPED,
'data-test-subj': 'dropdown-mapping-not_mapped',
},
]
);
export interface FieldMappingProps {
disabled: boolean;
mapping: CasesConfigurationMapping[] | null;
connectorActionTypeId: string;
onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void;
isLoading: boolean;
mappings: CaseConnectorMapping[];
}
const FieldMappingComponent: React.FC<FieldMappingProps> = ({
disabled,
mapping,
onChangeMapping,
connectorActionTypeId,
isLoading,
mappings,
}) => {
const onChangeActionType = useCallback(
(caseField: CaseField, newActionType: ActionType) => {
const myMapping = mapping ?? defaultMapping;
onChangeMapping(setActionTypeToMapping(caseField, newActionType, myMapping));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[mapping]
const selectedConnector = useMemo(
() => connectorsConfiguration[connectorActionTypeId] ?? { fields: {} },
[connectorActionTypeId]
);
const onChangeThirdParty = useCallback(
(caseField: CaseField, newThirdPartyField: ThirdPartyField) => {
const myMapping = mapping ?? defaultMapping;
onChangeMapping(setThirdPartyToMapping(caseField, newThirdPartyField, myMapping));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[mapping]
);
const selectedConnector = connectorsConfiguration[connectorActionTypeId] ?? { fields: {} };
const defaultMapping = useMemo(() => createDefaultMapping(selectedConnector.fields), [
selectedConnector.fields,
]);
return (
<>
<EuiFormRow fullWidth data-test-subj="case-configure-field-mapping-cols">
return mappings.length ? (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
{' '}
<EuiFlexGroup>
<EuiFlexItem>
<span className="euiFormLabel">{i18n.FIELD_MAPPING_FIRST_COL}</span>
</EuiFlexItem>
<EuiFlexItem>
<span className="euiFormLabel">{i18n.FIELD_MAPPING_SECOND_COL}</span>
<span className="euiFormLabel">
{i18n.FIELD_MAPPING_SECOND_COL(selectedConnector.name)}
</span>
</EuiFlexItem>
<EuiFlexItem>
<span className="euiFormLabel">{i18n.FIELD_MAPPING_THIRD_COL}</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<FieldRowWrapper data-test-subj="case-configure-field-mapping-row-wrapper">
{(mapping ?? defaultMapping).map((item) => (
<FieldMappingRow
key={`${item.source}`}
id={`${item.source}`}
disabled={disabled}
securitySolutionField={item.source}
thirdPartyOptions={getThirdPartyOptions(item.source, selectedConnector.fields)}
actionTypeOptions={actionTypeOptions}
onChangeActionType={onChangeActionType}
onChangeThirdParty={onChangeThirdParty}
selectedActionType={item.actionType}
selectedThirdParty={item.target ?? 'not_mapped'}
/>
))}
</FieldRowWrapper>
</>
);
</EuiFlexItem>
<EuiFlexItem>
<FieldRowWrapper data-test-subj="case-configure-field-mappings-row-wrapper">
{mappings.map((item) => (
<FieldMappingRowStatic
key={`${item.source}`}
securitySolutionField={item.source}
isLoading={isLoading}
selectedActionType={item.actionType}
selectedThirdParty={item.target ?? 'not_mapped'}
/>
))}
</FieldRowWrapper>
</EuiFlexItem>
</EuiFlexGroup>
) : null;
};
export const FieldMapping = React.memo(FieldMappingComponent);

View file

@ -1,114 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { EuiSuperSelectOption, EuiSuperSelect } from '@elastic/eui';
import { FieldMappingRow, RowProps } from './field_mapping_row';
import { TestProviders } from '../../../common/mock';
import { ThirdPartyField, ActionType } from '../../containers/configure/types';
const thirdPartyOptions: Array<EuiSuperSelectOption<ThirdPartyField>> = [
{
value: 'short_description',
inputDisplay: <span>{'Short Description'}</span>,
'data-test-subj': 'third-party-short-desc',
},
{
value: 'description',
inputDisplay: <span>{'Description'}</span>,
'data-test-subj': 'third-party-desc',
},
];
const actionTypeOptions: Array<EuiSuperSelectOption<ActionType>> = [
{
value: 'nothing',
inputDisplay: <>{'Nothing'}</>,
'data-test-subj': 'edit-update-option-nothing',
},
{
value: 'overwrite',
inputDisplay: <>{'Overwrite'}</>,
'data-test-subj': 'edit-update-option-overwrite',
},
{
value: 'append',
inputDisplay: <>{'Append'}</>,
'data-test-subj': 'edit-update-option-append',
},
];
describe('FieldMappingRow', () => {
let wrapper: ReactWrapper;
const onChangeActionType = jest.fn();
const onChangeThirdParty = jest.fn();
const props: RowProps = {
id: 'title',
disabled: false,
securitySolutionField: 'title',
thirdPartyOptions,
actionTypeOptions,
onChangeActionType,
onChangeThirdParty,
selectedActionType: 'nothing',
selectedThirdParty: 'short_description',
};
beforeAll(() => {
wrapper = mount(<FieldMappingRow {...props} />, { wrappingComponent: TestProviders });
});
test('it renders', () => {
expect(
wrapper.find('[data-test-subj="case-configure-third-party-select-title"]').first().exists()
).toBe(true);
expect(
wrapper.find('[data-test-subj="case-configure-action-type-select-title"]').first().exists()
).toBe(true);
});
test('it passes thirdPartyOptions correctly', () => {
const selectProps = wrapper.find(EuiSuperSelect).first().props();
expect(selectProps.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: 'short_description',
'data-test-subj': 'third-party-short-desc',
}),
expect.objectContaining({
value: 'description',
'data-test-subj': 'third-party-desc',
}),
])
);
});
test('it passes the correct actionTypeOptions', () => {
const selectProps = wrapper.find(EuiSuperSelect).at(1).props();
expect(selectProps.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: 'nothing',
'data-test-subj': 'edit-update-option-nothing',
}),
expect.objectContaining({
value: 'overwrite',
'data-test-subj': 'edit-update-option-overwrite',
}),
expect.objectContaining({
value: 'append',
'data-test-subj': 'edit-update-option-append',
}),
])
);
});
});

View file

@ -1,80 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import {
EuiFlexItem,
EuiFlexGroup,
EuiSuperSelect,
EuiIcon,
EuiSuperSelectOption,
} from '@elastic/eui';
import { capitalize } from 'lodash/fp';
import { CaseField, ActionType, ThirdPartyField } from '../../containers/configure/types';
import { AllThirdPartyFields } from '../connectors';
export interface RowProps {
id: string;
disabled: boolean;
securitySolutionField: CaseField;
thirdPartyOptions: Array<EuiSuperSelectOption<AllThirdPartyFields>>;
actionTypeOptions: Array<EuiSuperSelectOption<ActionType>>;
onChangeActionType: (caseField: CaseField, newActionType: ActionType) => void;
onChangeThirdParty: (caseField: CaseField, newThirdPartyField: ThirdPartyField) => void;
selectedActionType: ActionType;
selectedThirdParty: ThirdPartyField;
}
const FieldMappingRowComponent: React.FC<RowProps> = ({
id,
disabled,
securitySolutionField,
thirdPartyOptions,
actionTypeOptions,
onChangeActionType,
onChangeThirdParty,
selectedActionType,
selectedThirdParty,
}) => {
const securitySolutionFieldCapitalized = useMemo(() => capitalize(securitySolutionField), [
securitySolutionField,
]);
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiFlexGroup component="span" justifyContent="spaceBetween">
<EuiFlexItem component="span" grow={false}>
{securitySolutionFieldCapitalized}
</EuiFlexItem>
<EuiFlexItem component="span" grow={false}>
<EuiIcon type="sortRight" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiSuperSelect
disabled={disabled}
options={thirdPartyOptions}
valueOfSelected={selectedThirdParty}
onChange={onChangeThirdParty.bind(null, securitySolutionField)}
data-test-subj={`case-configure-third-party-select-${id}`}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiSuperSelect
disabled={disabled}
options={actionTypeOptions}
valueOfSelected={selectedActionType}
onChange={onChangeActionType.bind(null, securitySolutionField)}
data-test-subj={`case-configure-action-type-select-${id}`}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const FieldMappingRow = React.memo(FieldMappingRowComponent);

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