[Security Solutions][Case] Settings per case per connector (#77327)

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2020-10-06 20:03:46 +03:00 committed by GitHub
parent cc7ac29622
commit 287541891e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
154 changed files with 8636 additions and 983 deletions

View file

@ -74,6 +74,10 @@ describe('api', () => {
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
labels: ['kibana', 'elastic'],
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)',
@ -233,6 +237,10 @@ describe('api', () => {
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)',
summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)',
@ -443,6 +451,10 @@ describe('api', () => {
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)',
@ -480,6 +492,10 @@ describe('api', () => {
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)',
},
@ -516,6 +532,10 @@ describe('api', () => {
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:
@ -553,7 +573,12 @@ describe('api', () => {
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {},
incident: {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
},
});
});
@ -587,6 +612,10 @@ describe('api', () => {
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)',
},
});
@ -622,6 +651,10 @@ describe('api', () => {
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)',
@ -659,6 +692,10 @@ describe('api', () => {
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)',
},
@ -695,6 +732,10 @@ describe('api', () => {
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:
@ -733,6 +774,10 @@ describe('api', () => {
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)',
},

View file

@ -91,11 +91,25 @@ const pushToServiceHandler = async ({
defaultPipes,
});
incident = transformFields<PushToServiceApiParams, ExternalServiceParams, Incident>({
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 };

View file

@ -99,7 +99,21 @@ export const createExternalService = (
return fields;
};
const createErrorMessage = (errors: ResponseError) => {
const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => {
if (errorResponse == null) {
return '';
}
const { errorMessages, errors } = errorResponse;
if (errors == null) {
return '';
}
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
return `${errorMessages.join(', ')}`;
}
return Object.entries(errors).reduce((errorMessage, [, value]) => {
const msg = errorMessage.length > 0 ? `${errorMessage} ${value}` : value;
return msg;
@ -154,7 +168,7 @@ export const createExternalService = (
i18n.NAME,
`Unable to get incident with id ${id}. Error: ${
error.message
} Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}`
} Reason: ${createErrorMessage(error.response?.data)}`
)
);
}
@ -207,7 +221,7 @@ export const createExternalService = (
getErrorMessage(
i18n.NAME,
`Unable to create incident. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
error.response?.data
)}`
)
);
@ -249,7 +263,7 @@ export const createExternalService = (
i18n.NAME,
`Unable to update incident with id ${incidentId}. Error: ${
error.message
}. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}`
}. Reason: ${createErrorMessage(error.response?.data)}`
)
);
}
@ -280,7 +294,7 @@ export const createExternalService = (
i18n.NAME,
`Unable to create comment at incident with id ${incidentId}. Error: ${
error.message
}. Reason: ${createErrorMessage(error.response?.data?.errors ?? {})}`
}. Reason: ${createErrorMessage(error.response?.data)}`
)
);
}
@ -302,7 +316,7 @@ export const createExternalService = (
getErrorMessage(
i18n.NAME,
`Unable to get capabilities. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
error.response?.data
)}`
)
);
@ -342,7 +356,7 @@ export const createExternalService = (
getErrorMessage(
i18n.NAME,
`Unable to get issue types. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
error.response?.data
)}`
)
);
@ -388,7 +402,7 @@ export const createExternalService = (
getErrorMessage(
i18n.NAME,
`Unable to get fields. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
error.response?.data
)}`
)
);
@ -415,7 +429,7 @@ export const createExternalService = (
getErrorMessage(
i18n.NAME,
`Unable to get issues. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
error.response?.data
)}`
)
);
@ -439,7 +453,7 @@ export const createExternalService = (
getErrorMessage(
i18n.NAME,
`Unable to get issue with id ${id}. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
error.response?.data
)}`
)
);

View file

@ -199,5 +199,6 @@ export interface Fields {
[key: string]: string | string[] | { name: string } | { key: string } | { id: string };
}
export interface ResponseError {
[k: string]: string;
errorMessages: string[] | null | undefined;
errors: { [k: string]: string } | null | undefined;
}

View file

@ -74,6 +74,8 @@ describe('api', () => {
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)',
@ -175,6 +177,8 @@ describe('api', () => {
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)',
@ -298,6 +302,8 @@ describe('api', () => {
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)',
@ -335,6 +341,8 @@ describe('api', () => {
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)',
},
@ -371,6 +379,8 @@ describe('api', () => {
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:
@ -408,7 +418,10 @@ describe('api', () => {
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {},
incident: {
incidentTypes: [1001],
severityCode: 6,
},
});
});
@ -442,6 +455,8 @@ describe('api', () => {
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)',
},
});
@ -477,6 +492,8 @@ describe('api', () => {
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)',
@ -514,6 +531,8 @@ describe('api', () => {
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)',
},
@ -550,6 +569,8 @@ describe('api', () => {
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:
@ -588,6 +609,8 @@ describe('api', () => {
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)',
},

View file

@ -73,11 +73,23 @@ const pushToServiceHandler = async ({
defaultPipes,
});
incident = transformFields<PushToServiceApiParams, ExternalServiceParams, Incident>({
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 };

View file

@ -76,12 +76,16 @@ describe('api', () => {
externalService,
mapping,
params,
secrets: {},
secrets: { username: 'elastic', password: 'elastic' },
logger: mockedLogger,
});
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
severity: '1',
urgency: '2',
impact: '3',
caller_id: 'elastic',
description:
'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description:
@ -103,6 +107,9 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenCalledTimes(2);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
incident: {
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)',
@ -114,6 +121,9 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
incident: {
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)',
@ -184,6 +194,9 @@ describe('api', () => {
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)',
short_description:
@ -205,6 +218,9 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenCalledTimes(3);
expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, {
incident: {
severity: '1',
urgency: '2',
impact: '3',
description:
'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)',
short_description:
@ -215,6 +231,9 @@ describe('api', () => {
expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, {
incident: {
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)',
@ -258,6 +277,9 @@ describe('api', () => {
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:
@ -297,6 +319,9 @@ describe('api', () => {
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)',
},
@ -334,6 +359,9 @@ describe('api', () => {
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:
@ -370,9 +398,14 @@ describe('api', () => {
secrets: {},
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {},
incident: {
severity: '1',
urgency: '2',
impact: '3',
},
});
});
@ -407,6 +440,9 @@ describe('api', () => {
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)',
},
@ -444,6 +480,9 @@ describe('api', () => {
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:
@ -483,6 +522,9 @@ describe('api', () => {
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)',
},
@ -520,6 +562,9 @@ describe('api', () => {
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:
@ -559,6 +604,9 @@ describe('api', () => {
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)',
},

View file

@ -60,11 +60,23 @@ const pushToServiceHandler = async ({
defaultPipes,
});
incident = transformFields<PushToServiceApiParams, ExternalServiceParams, Incident>({
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 };
}

View file

@ -75,7 +75,7 @@ const executorParams: ExecutorSubActionPushParams = {
comment: 'test-alert comment',
severity: '1',
urgency: '2',
impact: '1',
impact: '3',
comments: [
{
commentId: 'case-comment-1',

View file

@ -10,6 +10,7 @@ import { NumberFromString } from '../saved_object';
import { UserRT } from '../user';
import { CommentResponseRt } from './comment';
import { CasesStatusResponseRt } from './status';
import { CaseConnectorRt, ESCaseConnector } from '../connectors';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
export { ActionTypeExecutorResult } from '../../../../actions/server/types';
@ -17,7 +18,7 @@ export { ActionTypeExecutorResult } from '../../../../actions/server/types';
const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]);
const CaseBasicRt = rt.type({
connector_id: rt.string,
connector: CaseConnectorRt,
description: rt.string,
status: StatusRt,
tags: rt.array(rt.string),
@ -60,6 +61,7 @@ export const CasePostRequestRt = rt.type({
description: rt.string,
tags: rt.array(rt.string),
title: rt.string,
connector: CaseConnectorRt,
});
export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt;
@ -115,6 +117,8 @@ export const CasesResponseRt = rt.array(CaseResponseRt);
* 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,
@ -130,15 +134,15 @@ export const ServiceConnectorCommentParamsRt = rt.type({
});
export const ServiceConnectorCaseParamsRt = rt.type({
savedObjectId: rt.string,
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]),
description: rt.union([rt.string, rt.null]),
comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]),
});
export const ServiceConnectorCaseResponseRt = rt.intersection([
@ -174,3 +178,8 @@ export type ServiceConnectorCaseParams = rt.TypeOf<typeof ServiceConnectorCasePa
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'> & {
connector?: ESCaseConnector;
};

View file

@ -8,9 +8,10 @@ import * as rt from 'io-ts';
import { ActionResult } from '../../../../actions/common';
import { UserRT } from '../user';
import { JiraFieldsRT } from '../connectors/jira';
import { ServiceNowFieldsRT } from '../connectors/servicenow';
import { ResilientFieldsRT } from '../connectors/resilient';
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
@ -31,9 +32,9 @@ const CaseFieldRT = rt.union([
]);
const ThirdPartyFieldRT = rt.union([
JiraFieldsRT,
ServiceNowFieldsRT,
ResilientFieldsRT,
JiraCaseFieldsRt,
ServiceNowCaseFieldsRT,
ResilientCaseFieldsRT,
rt.literal('not_mapped'),
]);
@ -62,14 +63,13 @@ export type CasesConnectorConfiguration = rt.TypeOf<typeof CasesConnectorConfigu
/** ********************************************************************** */
export type Connector = ActionResult;
export type ActionConnector = ActionResult;
// TO DO we will need to add this type rt.literal('close-by-thrid-party')
// TODO: we will need to add this type rt.literal('close-by-third-party')
const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]);
const CasesConfigureBasicRt = rt.type({
connector_id: rt.string,
connector_name: rt.string,
connector: CaseConnectorRt,
closure_type: ClosureTypeRT,
});
@ -97,8 +97,12 @@ export const CaseConfigureResponseRt = rt.intersection([
]);
export type ClosureType = rt.TypeOf<typeof ClosureTypeRT>;
export type CasesConfigure = rt.TypeOf<typeof CasesConfigureBasicRt>;
export type CasesConfigureRequest = rt.TypeOf<typeof CasesConfigureRequestRt>;
export type CasesConfigurePatch = rt.TypeOf<typeof CasesConfigurePatchRt>;
export type CasesConfigureAttributes = rt.TypeOf<typeof CaseConfigureAttributesRt>;
export type CasesConfigureResponse = rt.TypeOf<typeof CaseConfigureResponseRt>;
export type ESCasesConfigureAttributes = Omit<CasesConfigureAttributes, 'connector'> & {
connector: ESCaseConnector;
};

View file

@ -14,7 +14,7 @@ import { UserRT } from '../user';
const UserActionFieldRt = rt.array(
rt.union([
rt.literal('comment'),
rt.literal('connector_id'),
rt.literal('connector'),
rt.literal('description'),
rt.literal('pushed'),
rt.literal('tags'),

View file

@ -3,7 +3,80 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as rt from 'io-ts';
import { JiraFieldsRT } from './jira';
import { ResilientFieldsRT } from './resilient';
import { ServiceNowFieldsRT } from './servicenow';
export * from './jira';
export * from './servicenow';
export * from './resilient';
export const ConnectorFieldsRt = rt.union([
JiraFieldsRT,
ResilientFieldsRT,
ServiceNowFieldsRT,
rt.null,
]);
export enum ConnectorTypes {
jira = '.jira',
resilient = '.resilient',
servicenow = '.servicenow',
none = '.none',
}
const ConnectorJiraTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.jira),
fields: rt.union([JiraFieldsRT, rt.null]),
});
const ConnectorResillientTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.resilient),
fields: rt.union([ResilientFieldsRT, rt.null]),
});
const ConnectorServiceNowTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.servicenow),
fields: rt.union([ServiceNowFieldsRT, rt.null]),
});
const ConnectorNoneTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.none),
fields: rt.null,
});
export const ConnectorTypeFieldsRt = rt.union([
ConnectorJiraTypeFieldsRt,
ConnectorResillientTypeFieldsRt,
ConnectorServiceNowTypeFieldsRt,
ConnectorNoneTypeFieldsRt,
]);
export const CaseConnectorRt = rt.intersection([
rt.type({
id: rt.string,
name: rt.string,
}),
ConnectorTypeFieldsRt,
]);
export type CaseConnector = rt.TypeOf<typeof CaseConnectorRt>;
export type ConnectorTypeFields = rt.TypeOf<typeof ConnectorTypeFieldsRt>;
// we need to change these types back and forth for storing in ES (arrays overwrite, objects merge)
export type ConnectorFields = rt.TypeOf<typeof ConnectorFieldsRt>;
export type ESConnectorFields = Array<{
key: string;
value: unknown;
}>;
export type ESCaseConnectorTypes = ConnectorTypes;
export interface ESCaseConnector {
id: string;
name: string;
type: ESCaseConnectorTypes;
fields: ESConnectorFields | null;
}

View file

@ -6,10 +6,16 @@
import * as rt from 'io-ts';
export const JiraFieldsRT = rt.union([
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]),
parent: rt.union([rt.string, rt.null]),
});
export type JiraFieldsType = rt.TypeOf<typeof JiraFieldsRT>;

View file

@ -6,10 +6,15 @@
import * as rt from 'io-ts';
export const ResilientFieldsRT = rt.union([
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]),
});
export type ResilientFieldsType = rt.TypeOf<typeof ResilientFieldsRT>;

View file

@ -6,10 +6,16 @@
import * as rt from 'io-ts';
export const ServiceNowFieldsRT = rt.union([
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]),
urgency: rt.union([rt.string, rt.null]),
});
export type ServiceNowFieldsType = rt.TypeOf<typeof ServiceNowFieldsRT>;

View file

@ -5,6 +5,7 @@
*/
export * from './cases';
export * from './connectors';
export * from './runtime_types';
export * from './saved_object';
export * from './user';

View file

@ -127,7 +127,7 @@ export const createMockSavedObjectsRepository = ({
if (
type === CASE_CONFIGURE_SAVED_OBJECT &&
attributes.connector_id === 'throw-error-create'
attributes.connector.id === 'throw-error-create'
) {
throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing');
}
@ -151,7 +151,7 @@ export const createMockSavedObjectsRepository = ({
id: 'mock-configuration',
attributes,
updated_at: '2020-04-09T09:43:51.778Z',
version: attributes.connector_id === 'no-version' ? undefined : 'WzksMV0=',
version: attributes.connector.id === 'no-version' ? undefined : 'WzksMV0=',
};
caseConfigureSavedObject = [newConfiguration];
@ -194,7 +194,7 @@ export const createMockSavedObjectsRepository = ({
type,
updated_at: '2019-11-22T22:50:55.191Z',
attributes,
version: attributes.connector_id === 'no-version' ? undefined : 'WzE3LDFd',
version: attributes.connector?.id === 'no-version' ? undefined : 'WzE3LDFd',
};
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { mockCases, mockCasesErrorTriggerData, mockCaseComments } from './mock_saved_objects';
export * from './mock_saved_objects';
export { createMockSavedObjectsRepository } from './create_mock_so_repository';
export { createRouteContext } from './route_contexts';
export { authenticationMock } from './authc_mock';

View file

@ -6,19 +6,25 @@
import { SavedObject } from 'kibana/server';
import {
CaseAttributes,
ESCasesConfigureAttributes,
CommentAttributes,
CasesConfigureAttributes,
ESCaseAttributes,
ConnectorTypes,
} from '../../../../common/api';
export const mockCases: Array<SavedObject<CaseAttributes>> = [
export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
{
type: 'cases',
id: 'mock-id-1',
attributes: {
closed_at: null,
closed_by: null,
connector_id: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: [],
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
@ -47,7 +53,12 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
attributes: {
closed_at: null,
closed_by: null,
connector_id: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: [],
},
created_at: '2019-11-25T22:32:00.900Z',
created_by: {
full_name: 'elastic',
@ -76,7 +87,16 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
attributes: {
closed_at: null,
closed_by: null,
connector_id: '123',
connector: {
id: '123',
name: 'My connector',
type: ConnectorTypes.jira,
fields: [
{ key: 'issueType', value: 'Task' },
{ key: 'priority', value: 'High' },
{ key: 'parent', value: null },
],
},
created_at: '2019-11-25T22:32:17.947Z',
created_by: {
full_name: 'elastic',
@ -109,7 +129,16 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
email: 'testemail@elastic.co',
username: 'elastic',
},
connector_id: '123',
connector: {
id: '123',
name: 'My connector',
type: ConnectorTypes.jira,
fields: [
{ key: 'issueType', value: 'Task' },
{ key: 'priority', value: 'High' },
{ key: 'parent', value: null },
],
},
created_at: '2019-11-25T22:32:17.947Z',
created_by: {
full_name: 'elastic',
@ -134,7 +163,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [
},
];
export const mockCaseNoConnectorId: SavedObject<Partial<CaseAttributes>> = {
export const mockCaseNoConnectorId: SavedObject<Partial<ESCaseAttributes>> = {
type: 'cases',
id: 'mock-no-connector_id',
attributes: {
@ -266,13 +295,17 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
},
];
export const mockCaseConfigure: Array<SavedObject<CasesConfigureAttributes>> = [
export const mockCaseConfigure: Array<SavedObject<ESCasesConfigureAttributes>> = [
{
type: 'cases-configure',
id: 'mock-configuration-1',
attributes: {
connector_id: '123',
connector_name: 'My connector',
connector: {
id: '789',
name: 'My connector 3',
type: ConnectorTypes.jira,
fields: null,
},
closure_type: 'close-by-user',
created_at: '2020-04-09T09:43:51.778Z',
created_by: {

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CasePostRequest, CasesConfigureRequest } from '../../../../common/api';
import { CasePostRequest, CasesConfigureRequest, ConnectorTypes } from '../../../../common/api';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { FindActionResult } from '../../../../../actions/server/types';
@ -11,6 +11,12 @@ export const newCase: CasePostRequest = {
title: 'My new case',
description: 'A description',
tags: ['new', 'case'],
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
};
export const getActions = (): FindActionResult[] => [
@ -59,7 +65,11 @@ export const getActions = (): FindActionResult[] => [
];
export const newConfiguration: CasesConfigureRequest = {
connector_id: '456',
connector_name: 'My connector 2',
connector: {
id: '456',
name: 'My connector 2',
type: ConnectorTypes.jira,
fields: null,
},
closure_type: 'close-by-pushing',
};

View file

@ -16,7 +16,6 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he
import { RouteDeps } from '../../types';
import { escapeHatch, wrapError, flattenCaseSavedObject } from '../../utils';
import { CASE_COMMENTS_URL } from '../../../../../common/constants';
import { getConnectorId } from '../helpers';
export function initPatchCommentApi({
caseConfigureService,
@ -71,7 +70,7 @@ export function initPatchCommentApi({
// eslint-disable-next-line @typescript-eslint/naming-convention
const { username, full_name, email } = await caseService.getUser({ request, response });
const updatedDate = new Date().toISOString();
const [updatedComment, updatedCase, myCaseConfigure] = await Promise.all([
const [updatedComment, updatedCase] = await Promise.all([
caseService.patchComment({
client,
commentId: query.id,
@ -91,7 +90,6 @@ export function initPatchCommentApi({
},
version: myCase.version,
}),
caseConfigureService.find({ client }),
]);
const totalCommentsFindByCases = await caseService.getAllCaseComments({
@ -103,7 +101,7 @@ export function initPatchCommentApi({
perPage: 1,
},
});
const caseConfigureConnectorId = getConnectorId(myCaseConfigure);
const [comments] = await Promise.all([
caseService.getAllCaseComments({
client,
@ -142,7 +140,6 @@ export function initPatchCommentApi({
references: myCase.references,
},
comments: comments.saved_objects,
caseConfigureConnectorId,
})
),
});

View file

@ -16,7 +16,6 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he
import { escapeHatch, transformNewComment, wrapError, flattenCaseSavedObject } from '../../utils';
import { RouteDeps } from '../../types';
import { CASE_COMMENTS_URL } from '../../../../../common/constants';
import { getConnectorId } from '../helpers';
export function initPostCommentApi({
caseConfigureService,
@ -52,7 +51,7 @@ export function initPostCommentApi({
const { username, full_name, email } = await caseService.getUser({ request, response });
const createdDate = new Date().toISOString();
const [newComment, updatedCase, myCaseConfigure] = await Promise.all([
const [newComment, updatedCase] = await Promise.all([
caseService.postNewComment({
client,
attributes: transformNewComment({
@ -79,10 +78,8 @@ export function initPostCommentApi({
},
version: myCase.version,
}),
caseConfigureService.find({ client }),
]);
const caseConfigureConnectorId = getConnectorId(myCaseConfigure);
const totalCommentsFindByCases = await caseService.getAllCaseComments({
client,
caseId,
@ -130,7 +127,6 @@ export function initPostCommentApi({
references: myCase.references,
},
comments: comments.saved_objects,
caseConfigureConnectorId,
})
),
});

View file

@ -58,8 +58,12 @@ describe('GET configuration', () => {
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(200);
expect(res.payload).toEqual({
connector_id: '123',
connector_name: 'My connector',
connector: {
id: '789',
name: 'My connector 3',
type: '.jira',
fields: null,
},
closure_type: 'close-by-user',
created_at: '2020-04-09T09:43:51.778Z',
created_by: {
@ -91,6 +95,7 @@ describe('GET configuration', () => {
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(200);
expect(res.payload).toEqual({});
});

View file

@ -8,6 +8,7 @@ import { CaseConfigureResponseRt } from '../../../../../common/api';
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
import { transformESConnectorToCaseConnector } from '../helpers';
export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) {
router.get(
@ -21,11 +22,15 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps
const myCaseConfigure = await caseConfigureService.find({ client });
const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0]
?.attributes ?? { connector: null };
return response.ok({
body:
myCaseConfigure.saved_objects.length > 0
? CaseConfigureResponseRt.encode({
...myCaseConfigure.saved_objects[0].attributes,
...caseConfigureWithoutConnector,
connector: transformESConnectorToCaseConnector(connector),
version: myCaseConfigure.saved_objects[0].version ?? '',
})
: {},

View file

@ -16,6 +16,7 @@ import {
import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects';
import { initPatchCaseConfigure } from './patch_configure';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
import { ConnectorTypes } from '../../../../../common/api/connectors';
describe('PATCH configuration', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -50,6 +51,7 @@ describe('PATCH configuration', () => {
expect(res.payload).toEqual(
expect.objectContaining({
...mockCaseConfigure[0].attributes,
connector: { fields: null, id: '789', name: 'My connector 3', type: '.jira' },
closure_type: 'close-by-pushing',
updated_at: '2020-04-09T09:43:51.778Z',
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
@ -82,6 +84,7 @@ describe('PATCH configuration', () => {
expect(res.payload).toEqual(
expect.objectContaining({
...mockCaseConfigure[0].attributes,
connector: { fields: null, id: '789', name: 'My connector 3', type: '.jira' },
closure_type: 'close-by-pushing',
updated_at: '2020-04-09T09:43:51.778Z',
updated_by: { email: null, full_name: null, username: null },
@ -90,6 +93,44 @@ describe('PATCH configuration', () => {
);
});
it('patch configuration - connector', async () => {
routeHandler = await createRoute(initPatchCaseConfigure, 'patch');
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'patch',
body: {
connector: {
id: 'connector-new',
name: 'New connector',
type: '.jira',
fields: null,
},
version: mockCaseConfigure[0].version,
},
});
const context = createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
})
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(200);
expect(res.payload).toEqual(
expect.objectContaining({
...mockCaseConfigure[0].attributes,
connector: { id: 'connector-new', name: 'New connector', type: '.jira', fields: null },
closure_type: 'close-by-user',
updated_at: '2020-04-09T09:43:51.778Z',
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
version: 'WzE3LDFd',
})
);
});
it('throw error when configuration have not being created', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
@ -138,7 +179,15 @@ describe('PATCH configuration', () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'patch',
body: { connector_id: 'no-version', version: mockCaseConfigure[0].version },
body: {
connector: {
id: 'no-version',
name: 'no version',
type: ConnectorTypes.none,
fields: null,
},
version: mockCaseConfigure[0].version,
},
});
const context = createRouteContext(

View file

@ -17,6 +17,10 @@ import {
import { RouteDeps } from '../../types';
import { wrapError, escapeHatch } from '../../utils';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
import {
transformCaseConnectorToEsConnector,
transformESConnectorToCaseConnector,
} from '../helpers';
export function initPatchCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) {
router.patch(
@ -35,8 +39,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
);
const myCaseConfigure = await caseConfigureService.find({ client });
const { version, ...queryWithoutVersion } = query;
const { version, connector, ...queryWithoutVersion } = query;
if (myCaseConfigure.saved_objects.length === 0) {
throw Boom.conflict(
'You can not patch this configuration since you did not created first with a post.'
@ -58,6 +61,9 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
caseConfigureId: myCaseConfigure.saved_objects[0].id,
updatedAttributes: {
...queryWithoutVersion,
...(connector != null
? { connector: transformCaseConnectorToEsConnector(connector) }
: {}),
updated_at: updateDate,
updated_by: { email, full_name, username },
},
@ -67,6 +73,9 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
body: CaseConfigureResponseRt.encode({
...myCaseConfigure.saved_objects[0].attributes,
...patch.attributes,
connector: transformESConnectorToCaseConnector(
patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector
),
version: patch.version ?? '',
}),
});

View file

@ -17,6 +17,7 @@ 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';
import { ConnectorTypes } from '../../../../../common/api/connectors';
describe('POST configuration', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -47,8 +48,12 @@ describe('POST configuration', () => {
expect(res.status).toEqual(200);
expect(res.payload).toEqual(
expect.objectContaining({
connector_id: '456',
connector_name: 'My connector 2',
connector: {
id: '456',
name: 'My connector 2',
type: '.jira',
fields: null,
},
closure_type: 'close-by-pushing',
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
@ -78,8 +83,12 @@ describe('POST configuration', () => {
expect(res.status).toEqual(200);
expect(res.payload).toEqual(
expect.objectContaining({
connector_id: '456',
connector_name: 'My connector 2',
connector: {
id: '456',
name: 'My connector 2',
type: '.jira',
fields: null,
},
closure_type: 'close-by-pushing',
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: null, full_name: null, username: null },
@ -89,12 +98,16 @@ describe('POST configuration', () => {
);
});
it('throws when missing connector_id', async () => {
it('throws when missing connector.id', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
connector_name: 'My connector 2',
connector: {
name: 'My connector 2',
type: '.jira',
fields: null,
},
closure_type: 'close-by-pushing',
},
});
@ -110,12 +123,66 @@ describe('POST configuration', () => {
expect(res.payload.isBoom).toEqual(true);
});
it('throws when missing connector_name', async () => {
it('throws when missing connector.name', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
connector_id: '456',
connector: {
id: '456',
type: '.jira',
fields: null,
},
closure_type: 'close-by-pushing',
},
});
const context = createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
})
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload.isBoom).toEqual(true);
});
it('throws when missing connector.type', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
connector: {
id: '456',
name: 'My connector 2',
fields: null,
},
closure_type: 'close-by-pushing',
},
});
const context = createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
})
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload.isBoom).toEqual(true);
});
it('throws when missing connector.fields', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
connector: {
id: '456',
name: 'My connector 2',
type: ConnectorTypes.none,
},
closure_type: 'close-by-pushing',
},
});
@ -136,8 +203,12 @@ describe('POST configuration', () => {
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
connector_id: '456',
connector_name: 'My connector 2',
connector: {
id: '456',
name: 'My connector 2',
type: '.jira',
fields: null,
},
},
});
@ -254,8 +325,11 @@ describe('POST configuration', () => {
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
connector_id: 'throw-error-create',
connector_name: 'My connector 2',
connector: {
id: 'throw-error-create',
name: 'My connector 2',
fields: null,
},
closure_type: 'close-by-pushing',
},
});
@ -275,7 +349,15 @@ describe('POST configuration', () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: { ...newConfiguration, connector_id: 'no-version' },
body: {
...newConfiguration,
connector: {
id: 'no-version',
name: 'no version',
type: ConnectorTypes.none,
fields: null,
},
},
});
const context = createRouteContext(
@ -292,4 +374,46 @@ describe('POST configuration', () => {
})
);
});
it('returns an error if fields are not null', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
...newConfiguration,
connector: { id: 'not-null', name: 'not-null', type: ConnectorTypes.none, fields: {} },
},
});
const context = createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
})
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload.isBoom).toEqual(true);
});
it('returns an error if the type of the connector does not exists', async () => {
const req = httpServerMock.createKibanaRequest({
path: CASE_CONFIGURE_URL,
method: 'post',
body: {
...newConfiguration,
connector: { id: 'not-exists', name: 'not-exist', type: '.not-exists', fields: null },
},
});
const context = createRouteContext(
createMockSavedObjectsRepository({
caseConfigureSavedObject: mockCaseConfigure,
})
);
const res = await routeHandler(context, req, kibanaResponseFactory);
expect(res.status).toEqual(400);
expect(res.payload.isBoom).toEqual(true);
});
});

View file

@ -17,6 +17,10 @@ import {
import { RouteDeps } from '../../types';
import { wrapError, escapeHatch } from '../../utils';
import { CASE_CONFIGURE_URL } from '../../../../../common/constants';
import {
transformCaseConnectorToEsConnector,
transformESConnectorToCaseConnector,
} from '../helpers';
export function initPostCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) {
router.post(
@ -51,6 +55,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
client,
attributes: {
...query,
connector: transformCaseConnectorToEsConnector(query.connector),
created_at: creationDate,
created_by: { email, full_name, username },
updated_at: null,
@ -59,7 +64,12 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
});
return response.ok({
body: CaseConfigureResponseRt.encode({ ...post.attributes, version: post.version ?? '' }),
body: CaseConfigureResponseRt.encode({
...post.attributes,
// Reserve for future implementations
connector: transformESConnectorToCaseConnector(post.attributes.connector),
version: post.version ?? '',
}),
});
} catch (error) {
return response.customError(wrapError(error));

View file

@ -22,6 +22,7 @@ describe('FIND all cases', () => {
beforeAll(async () => {
routeHandler = await createRoute(initFindCasesApi, 'get');
});
it(`gets all the cases`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASES_URL}/_find`,
@ -38,7 +39,8 @@ describe('FIND all cases', () => {
expect(response.status).toEqual(200);
expect(response.payload.cases).toHaveLength(4);
});
it(`has proper connector id on cases with configured id`, async () => {
it(`has proper connector id on cases with configured connector`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASES_URL}/_find`,
method: 'get',
@ -52,8 +54,9 @@ describe('FIND all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases[2].connector_id).toEqual('123');
expect(response.payload.cases[2].connector.id).toEqual('123');
});
it(`adds 'none' connector id to cases without when 3rd party unconfigured`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASES_URL}/_find`,
@ -68,8 +71,9 @@ describe('FIND all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases[0].connector_id).toEqual('none');
expect(response.payload.cases[0].connector.id).toEqual('none');
});
it(`adds default connector id to cases without when 3rd party configured`, async () => {
const request = httpServerMock.createKibanaRequest({
path: `${CASES_URL}/_find`,
@ -85,6 +89,6 @@ describe('FIND all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases[0].connector_id).toEqual('123');
expect(response.payload.cases[0].connector.id).toEqual('none');
});
});

View file

@ -16,7 +16,6 @@ import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils';
import { RouteDeps, TotalCommentByCase } from '../types';
import { CASE_SAVED_OBJECT } from '../../../saved_object_types';
import { CASES_URL } from '../../../../common/constants';
import { getConnectorId } from './helpers';
const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string =>
filters?.filter((i) => i !== '').join(` ${operator} `);
@ -95,11 +94,10 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }:
filter: getStatusFilter('closed', myFilters),
},
};
const [cases, openCases, closesCases, myCaseConfigure] = await Promise.all([
const [cases, openCases, closesCases] = await Promise.all([
caseService.findCases(args),
caseService.findCases(argsOpenCases),
caseService.findCases(argsClosedCases),
caseConfigureService.find({ client }),
]);
const totalCommentsFindByCases = await Promise.all(
cases.saved_objects.map((c) =>
@ -136,8 +134,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }:
cases,
openCases.total ?? 0,
closesCases.total ?? 0,
totalCommentsByCases,
getConnectorId(myCaseConfigure)
totalCommentsByCases
)
),
});

View file

@ -7,7 +7,7 @@
import { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { CaseAttributes } from '../../../../common/api';
import { ConnectorTypes, ESCaseAttributes } from '../../../../common/api';
import {
createMockSavedObjectsRepository,
createRoute,
@ -15,11 +15,12 @@ import {
mockCases,
mockCasesErrorTriggerData,
mockCaseComments,
mockCaseNoConnectorId,
mockCaseConfigure,
} from '../__fixtures__';
import { flattenCaseSavedObject } from '../utils';
import { initGetCaseApi } from './get_case';
import { CASE_DETAILS_URL } from '../../../../common/constants';
import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects';
describe('GET case', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -46,12 +47,17 @@ describe('GET case', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
const savedObject = (mockCases.find((s) => s.id === 'mock-id-1') as unknown) as SavedObject<
CaseAttributes
ESCaseAttributes
>;
expect(response.status).toEqual(200);
expect(response.payload).toEqual(flattenCaseSavedObject({ savedObject }));
expect(response.payload).toEqual(
flattenCaseSavedObject({
savedObject,
})
);
expect(response.payload.comments).toEqual([]);
});
it(`returns an error when thrown from getCase`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASE_DETAILS_URL,
@ -75,6 +81,7 @@ describe('GET case', () => {
expect(response.status).toEqual(404);
expect(response.payload.isBoom).toEqual(true);
});
it(`returns the case with case comments when includeComments is true`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASE_DETAILS_URL,
@ -99,6 +106,7 @@ describe('GET case', () => {
expect(response.status).toEqual(200);
expect(response.payload.comments).toHaveLength(3);
});
it(`returns an error when thrown from getAllCaseComments`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASE_DETAILS_URL,
@ -121,7 +129,8 @@ describe('GET case', () => {
expect(response.status).toEqual(400);
});
it(`case w/o connector_id - returns the case with connector id when 3rd party unconfigured`, async () => {
it(`case w/o connector.id - returns the case with connector id when 3rd party unconfigured`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASE_DETAILS_URL,
method: 'get',
@ -142,9 +151,15 @@ describe('GET case', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector_id).toEqual('none');
expect(response.payload.connector).toEqual({
fields: null,
id: 'none',
name: 'none',
type: ConnectorTypes.none,
});
});
it(`case w/o connector_id - returns the case with connector id when 3rd party configured`, async () => {
it(`case w/o connector.id - returns the case with connector id when 3rd party configured`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASE_DETAILS_URL,
method: 'get',
@ -166,9 +181,15 @@ describe('GET case', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector_id).toEqual('123');
expect(response.payload.connector).toEqual({
fields: null,
id: 'none',
name: 'none',
type: '.none',
});
});
it(`case w/ connector_id - returns the case with connector id when case already has connectorId`, async () => {
it(`case w/ connector.id - returns the case with connector id when case already has connectorId`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASE_DETAILS_URL,
method: 'get',
@ -190,6 +211,11 @@ describe('GET case', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector_id).toEqual('123');
expect(response.payload.connector).toEqual({
fields: { issueType: 'Task', priority: 'High', parent: null },
id: '123',
name: 'My connector',
type: '.jira',
});
});
});

View file

@ -10,7 +10,6 @@ import { CaseResponseRt } from '../../../../common/api';
import { RouteDeps } from '../types';
import { flattenCaseSavedObject, wrapError } from '../utils';
import { CASE_DETAILS_URL } from '../../../../common/constants';
import { getConnectorId } from './helpers';
export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) {
router.get(
@ -30,22 +29,18 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro
const client = context.core.savedObjects.client;
const includeComments = JSON.parse(request.query.includeComments);
const [theCase, myCaseConfigure] = await Promise.all([
const [theCase] = await Promise.all([
caseService.getCase({
client,
caseId: request.params.case_id,
}),
caseConfigureService.find({ client }),
]);
const caseConfigureConnectorId = getConnectorId(myCaseConfigure);
if (!includeComments) {
return response.ok({
body: CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: theCase,
caseConfigureConnectorId,
})
),
});
@ -66,7 +61,6 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro
savedObject: theCase,
comments: theComments.saved_objects,
totalComment: theComments.total,
caseConfigureConnectorId,
})
),
});

View file

@ -0,0 +1,110 @@
/*
* 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 { SavedObjectsFindResponse } from 'kibana/server';
import {
CaseConnector,
ConnectorTypes,
ESCaseConnector,
ESCasesConfigureAttributes,
} from '../../../../common/api';
import { mockCaseConfigure } from '../__fixtures__';
import {
transformCaseConnectorToEsConnector,
transformESConnectorToCaseConnector,
getConnectorFromConfiguration,
} from './helpers';
describe('helpers', () => {
const caseConnector: CaseConnector = {
id: '123',
name: 'Jira',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: 'High', parent: null },
};
const esCaseConnector: ESCaseConnector = {
id: '123',
name: 'Jira',
type: ConnectorTypes.jira,
fields: [
{ key: 'issueType', value: 'Task' },
{ key: 'priority', value: 'High' },
{ key: 'parent', value: null },
],
};
const caseConfigure: SavedObjectsFindResponse<ESCasesConfigureAttributes> = {
saved_objects: [{ ...mockCaseConfigure[0], score: 0 }],
total: 1,
per_page: 20,
page: 1,
};
describe('transformCaseConnectorToEsConnector', () => {
it('transform correctly', () => {
expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector);
});
it('transform correctly with null attributes', () => {
// @ts-ignore this is case the connector does not exist for old cases object or configurations
expect(transformCaseConnectorToEsConnector(null)).toEqual({
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: [],
});
});
});
describe('transformESConnectorToCaseConnector', () => {
it('transform correctly', () => {
expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector);
});
it('transform correctly with null attributes', () => {
// @ts-ignore this is case the connector does not exist for old cases object or configurations
expect(transformESConnectorToCaseConnector(null)).toEqual({
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
});
});
});
describe('getConnectorFromConfiguration', () => {
it('transform correctly', () => {
expect(getConnectorFromConfiguration(caseConfigure)).toEqual({
id: '789',
name: 'My connector 3',
type: ConnectorTypes.jira,
fields: null,
});
});
it('transform correctly with no connector', () => {
const caseConfigureNoConnector: SavedObjectsFindResponse<ESCasesConfigureAttributes> = {
...caseConfigure,
saved_objects: [
{
...mockCaseConfigure[0],
// @ts-ignore this is case the connector does not exist for old cases object or configurations
attributes: { ...mockCaseConfigure[0].attributes, connector: null },
score: 0,
},
],
};
expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
});
});
});
});

View file

@ -4,10 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import { get, isPlainObject } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { SavedObjectsFindResponse } from 'kibana/server';
import { CaseAttributes, CasePatchRequest, CasesConfigureAttributes } from '../../../../common/api';
import {
CaseConnector,
ESCaseConnector,
ESCaseAttributes,
ESCasePatchRequest,
ESCasesConfigureAttributes,
ConnectorTypes,
} from '../../../../common/api';
import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors';
interface CompareArrays {
addedItems: string[];
@ -57,9 +66,9 @@ export const isTwoArraysDifference = (
};
export const getCaseToUpdate = (
currentCase: CaseAttributes,
queryCase: CasePatchRequest
): CasePatchRequest =>
currentCase: ESCaseAttributes,
queryCase: ESCasePatchRequest
): ESCasePatchRequest =>
Object.entries(queryCase).reduce(
(acc, [key, value]) => {
const currentValue = get(currentCase, key);
@ -70,26 +79,89 @@ export const getCaseToUpdate = (
[key]: value,
};
}
return acc;
} else if (isPlainObject(currentValue) && isPlainObject(value)) {
if (!deepEqual(currentValue, value)) {
return {
...acc,
[key]: value,
};
}
return acc;
} else if (currentValue != null && value !== currentValue) {
return {
...acc,
[key]: value,
};
} else if (currentValue == null && key === 'connector_id' && value !== currentValue) {
return {
...acc,
[key]: value,
};
}
return acc;
},
{ id: queryCase.id, version: queryCase.version }
);
export const getConnectorId = (
caseConfigure: SavedObjectsFindResponse<CasesConfigureAttributes>
): string =>
caseConfigure.saved_objects.length > 0
? caseConfigure.saved_objects[0].attributes.connector_id
: 'none';
export const getNoneCaseConnector = () => ({
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
});
export const getConnectorFromConfiguration = (
caseConfigure: SavedObjectsFindResponse<ESCasesConfigureAttributes>
): CaseConnector => {
let caseConnector = getNoneCaseConnector();
if (
caseConfigure.saved_objects.length > 0 &&
caseConfigure.saved_objects[0].attributes.connector
) {
caseConnector = {
id: caseConfigure.saved_objects[0].attributes.connector.id,
name: caseConfigure.saved_objects[0].attributes.connector.name,
type: caseConfigure.saved_objects[0].attributes.connector.type,
fields: null,
};
}
return caseConnector;
};
export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({
id: connector?.id ?? 'none',
name: connector?.name ?? 'none',
type: connector?.type ?? '.none',
fields:
connector?.fields != null
? Object.entries(connector.fields).reduce<ESConnectorFields>(
(acc, [key, value]) => [
...acc,
{
key,
value,
},
],
[]
)
: [],
});
export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => {
const connectorTypeField = {
type: connector?.type ?? '.none',
fields:
connector && connector.fields != null && connector.fields.length > 0
? connector.fields.reduce(
(fields, { key, value }) => ({
...fields,
[key]: value,
}),
{}
)
: null,
} as ConnectorTypeFields;
return {
id: connector?.id ?? 'none',
name: connector?.name ?? 'none',
...connectorTypeField,
};
};

View file

@ -16,6 +16,7 @@ import {
} from '../__fixtures__';
import { initPatchCasesApi } from './patch_cases';
import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects';
import { ConnectorTypes } from '../../../../common/api/connectors';
describe('PATCH cases', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -26,6 +27,7 @@ describe('PATCH cases', () => {
toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'),
}));
});
it(`Close a case`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
@ -54,7 +56,12 @@ describe('PATCH cases', () => {
closed_at: '2019-11-25T21:54:48.952Z',
closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
comments: [],
connector_id: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
description: 'This is a brand new case of a bad meanie defacing data',
@ -70,6 +77,7 @@ describe('PATCH cases', () => {
},
]);
});
it(`Open a case`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
@ -99,7 +107,12 @@ describe('PATCH cases', () => {
closed_at: null,
closed_by: null,
comments: [],
connector_id: '123',
connector: {
id: '123',
name: 'My connector',
type: '.jira',
fields: { issueType: 'Task', priority: 'High', parent: null },
},
created_at: '2019-11-25T22:32:17.947Z',
created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
description: 'Oh no, a bad meanie going LOLBins all over the place!',
@ -115,7 +128,8 @@ describe('PATCH cases', () => {
},
]);
});
it(`Patches a case without a connector_id`, async () => {
it(`Patches a case without a connector.id`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
@ -138,9 +152,10 @@ describe('PATCH cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload[0].connector_id).toEqual('none');
expect(response.payload[0].connector.id).toEqual('none');
});
it(`Patches a case with a connector_id`, async () => {
it(`Patches a case with a connector.id`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
@ -163,8 +178,45 @@ describe('PATCH cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload[0].connector_id).toEqual('123');
expect(response.payload[0].connector.id).toEqual('123');
});
it(`Change connector`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
body: {
cases: [
{
id: 'mock-id-3',
connector: {
id: '456',
name: 'My connector 2',
type: '.jira',
fields: { issueType: 'Bug', priority: 'Low', parent: null },
},
version: 'WzUsMV0=',
},
],
},
});
const theContext = createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload[0].connector).toEqual({
id: '456',
name: 'My connector 2',
type: '.jira',
fields: { issueType: 'Bug', priority: 'Low', parent: null },
});
});
it(`Fails with 409 if version does not match`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
@ -189,6 +241,7 @@ describe('PATCH cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(409);
});
it(`Fails with 406 if updated field is unchanged`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
@ -214,6 +267,7 @@ describe('PATCH cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(406);
});
it(`Returns an error if updateCase throws`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',

View file

@ -15,10 +15,11 @@ import {
CasePatchRequest,
excess,
throwErrors,
ESCasePatchRequest,
} from '../../../../common/api';
import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils';
import { RouteDeps } from '../types';
import { getCaseToUpdate, getConnectorId } from './helpers';
import { getCaseToUpdate, transformCaseConnectorToEsConnector } from './helpers';
import { buildCaseUserActions } from '../../../services/user_actions/helpers';
import { CASES_URL } from '../../../../common/constants';
@ -43,14 +44,10 @@ export function initPatchCasesApi({
fold(throwErrors(Boom.badRequest), identity)
);
const [myCases, myCaseConfigure] = await Promise.all([
caseService.getCases({
client,
caseIds: query.cases.map((q) => q.id),
}),
caseConfigureService.find({ client }),
]);
const caseConfigureConnectorId = getConnectorId(myCaseConfigure);
const myCases = await caseService.getCases({
client,
caseIds: query.cases.map((q) => q.id),
});
let nonExistingCases: CasePatchRequest[] = [];
const conflictedCases = query.cases.filter((q) => {
@ -76,16 +73,25 @@ export function initPatchCasesApi({
.join(', ')} has been updated. Please refresh before saving additional updates.`
);
}
const updateCases: CasePatchRequest[] = query.cases.map((thisCase) => {
const currentCase = myCases.saved_objects.find((c) => c.id === thisCase.id);
const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => {
const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id);
const { connector, ...thisCase } = updateCase;
return currentCase != null
? getCaseToUpdate(currentCase.attributes, thisCase)
? getCaseToUpdate(currentCase.attributes, {
...thisCase,
...(connector != null
? { connector: transformCaseConnectorToEsConnector(connector) }
: {}),
})
: { id: thisCase.id, version: thisCase.version };
});
const updateFilterCases = updateCases.filter((updateCase) => {
const { id, version, ...updateCaseAttributes } = updateCase;
return Object.keys(updateCaseAttributes).length > 0;
});
if (updateFilterCases.length > 0) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { username, full_name, email } = await caseService.getUser({ request, response });
@ -133,7 +139,6 @@ export function initPatchCasesApi({
references: myCase.references,
version: updatedCase?.version ?? myCase.version,
},
caseConfigureConnectorId,
});
});

View file

@ -16,6 +16,7 @@ import {
import { initPostCaseApi } from './post_case';
import { CASES_URL } from '../../../../common/constants';
import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects';
import { ConnectorTypes } from '../../../../common/api/connectors';
describe('POST cases', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -26,6 +27,7 @@ describe('POST cases', () => {
toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'),
}));
});
it(`Posts a new case, no connector configured`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASES_URL,
@ -34,6 +36,12 @@ describe('POST cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Security Issue',
tags: ['defacement'],
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
},
});
@ -47,9 +55,15 @@ describe('POST cases', () => {
expect(response.status).toEqual(200);
expect(response.payload.id).toEqual('mock-it');
expect(response.payload.created_by.username).toEqual('awesome');
expect(response.payload.connector_id).toEqual('none');
expect(response.payload.connector).toEqual({
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
});
});
it(`Posts a new case, connector configured`, async () => {
it(`Posts a new case, connector provided`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASES_URL,
method: 'post',
@ -57,6 +71,12 @@ describe('POST cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Security Issue',
tags: ['defacement'],
connector: {
id: '123',
name: 'Jira',
type: '.jira',
fields: { issueType: 'Task', priority: 'High', parent: null },
},
},
});
@ -69,7 +89,12 @@ describe('POST cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.connector_id).toEqual('123');
expect(response.payload.connector).toEqual({
id: '123',
name: 'Jira',
type: '.jira',
fields: { issueType: 'Task', priority: 'High', parent: null },
});
});
it(`Error if you passing status for a new case`, async () => {
@ -81,6 +106,7 @@ describe('POST cases', () => {
title: 'Super Bad Security Issue',
status: 'open',
tags: ['defacement'],
connector: null,
},
});
@ -93,6 +119,7 @@ describe('POST cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(400);
});
it(`Returns an error if postNewCase throws`, async () => {
const request = httpServerMock.createKibanaRequest({
path: CASES_URL,
@ -101,6 +128,7 @@ describe('POST cases', () => {
description: 'Throw an error',
title: 'Super Bad Security Issue',
tags: ['error'],
connector: null,
},
});
@ -114,6 +142,7 @@ describe('POST cases', () => {
expect(response.status).toEqual(400);
expect(response.payload.isBoom).toEqual(true);
});
it(`Allow user to create case without authentication`, async () => {
routeHandler = await createRoute(initPostCaseApi, 'post', true);
@ -124,6 +153,12 @@ describe('POST cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Security Issue',
tags: ['defacement'],
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
},
});
@ -140,7 +175,12 @@ describe('POST cases', () => {
closed_at: null,
closed_by: null,
comments: [],
connector_id: '123',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
email: null,

View file

@ -15,7 +15,7 @@ import { CasePostRequestRt, throwErrors, excess, CaseResponseRt } from '../../..
import { buildCaseUserActionItem } from '../../../services/user_actions/helpers';
import { RouteDeps } from '../types';
import { CASES_URL } from '../../../../common/constants';
import { getConnectorId } from './helpers';
import { getConnectorFromConfiguration, transformCaseConnectorToEsConnector } from './helpers';
export function initPostCaseApi({
caseService,
@ -42,7 +42,8 @@ export function initPostCaseApi({
const { username, full_name, email } = await caseService.getUser({ request, response });
const createdDate = new Date().toISOString();
const myCaseConfigure = await caseConfigureService.find({ client });
const connectorId = getConnectorId(myCaseConfigure);
const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure);
const newCase = await caseService.postNewCase({
client,
attributes: transformNewCase({
@ -51,7 +52,9 @@ export function initPostCaseApi({
username,
full_name,
email,
connectorId,
connector: transformCaseConnectorToEsConnector(
query.connector ?? caseConfigureConnector
),
}),
});

View file

@ -6,6 +6,7 @@
import { schema } from '@kbn/config-schema';
import Boom from 'boom';
import isEmpty from 'lodash/isEmpty';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
@ -16,7 +17,6 @@ import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../..
import { buildCaseUserActionItem } from '../../../services/user_actions/helpers';
import { RouteDeps } from '../types';
import { CASE_DETAILS_URL } from '../../../../common/constants';
import { getConnectorId } from './helpers';
export function initPushCaseUserActionApi({
caseConfigureService,
@ -94,14 +94,13 @@ export function initPushCaseUserActionApi({
...query,
};
const caseConfigureConnectorId = getConnectorId(myCaseConfigure);
const updateConnector = myCase.attributes.connector;
// old case may not have new attribute connector_id, so we default to the configured system
const updateConnectorId = {
connector_id: myCase.attributes.connector_id ?? caseConfigureConnectorId,
};
if (!connectors.some((connector) => connector.id === updateConnectorId.connector_id)) {
if (
isEmpty(updateConnector) ||
(updateConnector != null && updateConnector.id === 'none') ||
!connectors.some((connector) => connector.id === updateConnector.id)
) {
throw Boom.notFound('Connector not found or set to none');
}
@ -121,7 +120,6 @@ export function initPushCaseUserActionApi({
external_service: externalService,
updated_at: pushedDate,
updated_by: { username, full_name, email },
...updateConnectorId,
},
version: myCase.version,
}),

View file

@ -23,13 +23,24 @@ import {
mockCaseComments,
mockCaseNoConnectorId,
} from './__fixtures__/mock_saved_objects';
import { ConnectorTypes, ESCaseConnector } from '../../../common/api';
describe('Utils', () => {
describe('transformNewCase', () => {
const connector: ESCaseConnector = {
id: '123',
name: 'My connector',
type: ConnectorTypes.jira,
fields: [
{ key: 'issueType', value: 'Task' },
{ key: 'priority', value: 'High' },
{ key: 'parent', value: null },
],
};
it('transform correctly', () => {
const myCase = {
newCase,
connectorId: '123',
connector,
createdDate: '2020-04-09T09:43:51.778Z',
email: 'elastic@elastic.co',
full_name: 'Elastic',
@ -42,7 +53,7 @@ describe('Utils', () => {
...myCase.newCase,
closed_at: null,
closed_by: null,
connector_id: '123',
connector,
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' },
external_service: null,
@ -55,7 +66,7 @@ describe('Utils', () => {
it('transform correctly without optional fields', () => {
const myCase = {
newCase,
connectorId: '123',
connector,
createdDate: '2020-04-09T09:43:51.778Z',
};
@ -65,7 +76,7 @@ describe('Utils', () => {
...myCase.newCase,
closed_at: null,
closed_by: null,
connector_id: '123',
connector,
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: undefined, full_name: undefined, username: undefined },
external_service: null,
@ -78,7 +89,7 @@ describe('Utils', () => {
it('transform correctly with optional fields as null', () => {
const myCase = {
newCase,
connectorId: '123',
connector,
createdDate: '2020-04-09T09:43:51.778Z',
email: null,
full_name: null,
@ -91,7 +102,7 @@ describe('Utils', () => {
...myCase.newCase,
closed_at: null,
closed_by: null,
connector_id: '123',
connector,
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: null, full_name: null, username: null },
external_service: null,
@ -230,8 +241,7 @@ describe('Utils', () => {
},
2,
2,
extraCaseData,
'123'
extraCaseData
);
expect(res).toEqual({
page: 1,
@ -239,8 +249,7 @@ describe('Utils', () => {
total: mockCases.length,
cases: flattenCaseSavedObjects(
mockCases.map((obj) => ({ ...obj, score: 1 })),
extraCaseData,
'123'
extraCaseData
),
count_open_cases: 2,
count_closed_cases: 2,
@ -252,13 +261,19 @@ describe('Utils', () => {
it('flattens correctly', () => {
const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }];
const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123');
const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData);
expect(res).toEqual([
{
id: 'mock-id-1',
closed_at: null,
closed_by: null,
connector_id: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
@ -285,14 +300,19 @@ describe('Utils', () => {
it('it handles total comments correctly when caseId is not in extraCaseData', () => {
const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }];
const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123');
const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData);
expect(res).toEqual([
{
id: 'mock-id-1',
closed_at: null,
closed_by: null,
connector_id: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
@ -316,7 +336,8 @@ describe('Utils', () => {
},
]);
});
it('inserts missing connectorId', () => {
it('inserts missing connector', () => {
const extraCaseData = [
{
caseId: mockCaseNoConnectorId.id,
@ -324,53 +345,20 @@ describe('Utils', () => {
},
];
// @ts-ignore this is to update old case saved objects to include connector_id
const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData, '123');
expect(res).toEqual([
{
id: mockCaseNoConnectorId.id,
closed_at: null,
closed_by: null,
connector_id: '123',
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
comments: [],
totalComment: 0,
version: 'WzAsMV0=',
},
]);
});
it('inserts missing connectorId (none)', () => {
const extraCaseData = [
{
caseId: mockCaseNoConnectorId.id,
totalComment: 0,
},
];
// @ts-ignore this is to update old case saved objects to include connector_id
// @ts-ignore this is to update old case saved objects to include connector
const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData);
expect(res).toEqual([
{
id: mockCaseNoConnectorId.id,
closed_at: null,
closed_by: null,
connector_id: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
@ -398,90 +386,89 @@ describe('Utils', () => {
describe('flattenCaseSavedObject', () => {
it('flattens correctly', () => {
const myCase = { ...mockCases[0] };
const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 });
const myCase = { ...mockCases[2] };
const res = flattenCaseSavedObject({
savedObject: myCase,
totalComment: 2,
});
expect(res).toEqual({
id: myCase.id,
version: myCase.version,
comments: [],
totalComment: 2,
...myCase.attributes,
connector: {
...myCase.attributes.connector,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
});
});
it('flattens correctly without version', () => {
const myCase = { ...mockCases[0] };
const myCase = { ...mockCases[2] };
myCase.version = undefined;
const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 });
const res = flattenCaseSavedObject({
savedObject: myCase,
totalComment: 2,
});
expect(res).toEqual({
id: myCase.id,
version: '0',
comments: [],
totalComment: 2,
...myCase.attributes,
connector: {
...myCase.attributes.connector,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
});
});
it('flattens correctly with comments', () => {
const myCase = { ...mockCases[0] };
const myCase = { ...mockCases[2] };
const comments = [{ ...mockCaseComments[0] }];
const res = flattenCaseSavedObject({ savedObject: myCase, comments, totalComment: 2 });
const res = flattenCaseSavedObject({
savedObject: myCase,
comments,
totalComment: 2,
});
expect(res).toEqual({
id: myCase.id,
version: myCase.version,
comments: flattenCommentSavedObjects(comments),
totalComment: 2,
...myCase.attributes,
connector: {
...myCase.attributes.connector,
fields: { issueType: 'Task', priority: 'High', parent: null },
},
});
});
it('inserts missing connectorId', () => {
it('inserts missing connector', () => {
const extraCaseData = {
totalComment: 2,
caseConfigureConnectorId: '123',
};
// @ts-ignore this is to update old case saved objects to include connector_id
const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData });
const res = flattenCaseSavedObject({
// @ts-ignore this is to update old case saved objects to include connector
savedObject: mockCaseNoConnectorId,
...extraCaseData,
});
expect(res).toEqual({
id: mockCaseNoConnectorId.id,
closed_at: null,
closed_by: null,
connector_id: '123',
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
full_name: 'elastic',
email: 'testemail@elastic.co',
username: 'elastic',
},
comments: [],
totalComment: 2,
version: 'WzAsMV0=',
});
});
it('inserts missing connectorId (none)', () => {
const extraCaseData = {
totalComment: 2,
caseConfigureConnectorId: 'none',
};
// @ts-ignore this is to update old case saved objects to include connector_id
const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData });
expect(res).toEqual({
id: mockCaseNoConnectorId.id,
closed_at: null,
closed_by: null,
connector_id: 'none',
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',

View file

@ -17,16 +17,18 @@ import {
CasePostRequest,
CaseResponse,
CasesFindResponse,
CaseAttributes,
CommentResponse,
CommentsResponse,
CommentAttributes,
ESCaseConnector,
ESCaseAttributes,
} from '../../../common/api';
import { transformESConnectorToCaseConnector } from './cases/helpers';
import { SortFieldCase, TotalCommentByCase } from './types';
export const transformNewCase = ({
connectorId,
connector,
createdDate,
email,
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -34,17 +36,17 @@ export const transformNewCase = ({
newCase,
username,
}: {
connectorId: string;
connector: ESCaseConnector;
createdDate: string;
email?: string | null;
full_name?: string | null;
newCase: CasePostRequest;
username?: string | null;
}): CaseAttributes => ({
}): ESCaseAttributes => ({
...newCase,
closed_at: null,
closed_by: null,
connector_id: connectorId,
connector,
created_at: createdDate,
created_by: { email, full_name, username },
external_service: null,
@ -88,33 +90,30 @@ export function wrapError(error: any): CustomHttpResponseOptions<ResponseError>
}
export const transformCases = (
cases: SavedObjectsFindResponse<CaseAttributes>,
cases: SavedObjectsFindResponse<ESCaseAttributes>,
countOpenCases: number,
countClosedCases: number,
totalCommentByCase: TotalCommentByCase[],
caseConfigureConnectorId: string = 'none'
totalCommentByCase: TotalCommentByCase[]
): CasesFindResponse => ({
page: cases.page,
per_page: cases.per_page,
total: cases.total,
cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase, caseConfigureConnectorId),
cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase),
count_open_cases: countOpenCases,
count_closed_cases: countClosedCases,
});
export const flattenCaseSavedObjects = (
savedObjects: Array<SavedObject<CaseAttributes>>,
totalCommentByCase: TotalCommentByCase[],
caseConfigureConnectorId: string = 'none'
savedObjects: Array<SavedObject<ESCaseAttributes>>,
totalCommentByCase: TotalCommentByCase[]
): CaseResponse[] =>
savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject<CaseAttributes>) => {
savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject<ESCaseAttributes>) => {
return [
...acc,
flattenCaseSavedObject({
savedObject,
totalComment:
totalCommentByCase.find((tc) => tc.caseId === savedObject.id)?.totalComments ?? 0,
caseConfigureConnectorId,
}),
];
}, []);
@ -123,19 +122,17 @@ export const flattenCaseSavedObject = ({
savedObject,
comments = [],
totalComment = 0,
caseConfigureConnectorId = 'none',
}: {
savedObject: SavedObject<CaseAttributes>;
savedObject: SavedObject<ESCaseAttributes>;
comments?: Array<SavedObject<CommentAttributes>>;
totalComment?: number;
caseConfigureConnectorId?: string;
}): CaseResponse => ({
id: savedObject.id,
version: savedObject.version ?? '0',
comments: flattenCommentSavedObjects(comments),
totalComment,
...savedObject.attributes,
connector_id: savedObject.attributes.connector_id ?? caseConfigureConnectorId,
connector: transformESConnectorToCaseConnector(savedObject.attributes.connector),
});
export const transformComments = (

View file

@ -5,6 +5,7 @@
*/
import { SavedObjectsType } from 'src/core/server';
import { caseMigrations } from './migrations';
export const CASE_SAVED_OBJECT = 'cases';
@ -49,8 +50,28 @@ export const caseSavedObjectType: SavedObjectsType = {
description: {
type: 'text',
},
connector_id: {
type: 'keyword',
connector: {
properties: {
id: {
type: 'keyword',
},
name: {
type: 'text',
},
type: {
type: 'keyword',
},
fields: {
properties: {
key: {
type: 'text',
},
value: {
type: 'text',
},
},
},
},
},
external_service: {
properties: {
@ -115,4 +136,5 @@ export const caseSavedObjectType: SavedObjectsType = {
},
},
},
migrations: caseMigrations,
};

View file

@ -5,6 +5,7 @@
*/
import { SavedObjectsType } from 'src/core/server';
import { configureMigrations } from './migrations';
export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure';
@ -30,11 +31,28 @@ export const caseConfigureSavedObjectType: SavedObjectsType = {
},
},
},
connector_id: {
type: 'keyword',
},
connector_name: {
type: 'keyword',
connector: {
properties: {
id: {
type: 'keyword',
},
name: {
type: 'text',
},
type: {
type: 'keyword',
},
fields: {
properties: {
key: {
type: 'text',
},
value: {
type: 'text',
},
},
},
},
},
closure_type: {
type: 'keyword',
@ -57,4 +75,5 @@ export const caseConfigureSavedObjectType: SavedObjectsType = {
},
},
},
migrations: configureMigrations,
};

View file

@ -0,0 +1,128 @@
/*
* 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/naming-convention */
import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server';
import { ConnectorTypes } from '../../common/api/connectors';
interface UnsanitizedCase {
connector_id: string;
}
interface UnsanitizedConfigure {
connector_id: string;
connector_name: string;
}
interface SanitizedCase {
connector: {
id: string;
name: string | null;
type: string | null;
fields: null;
};
}
interface SanitizedConfigure {
connector: {
id: string;
name: string | null;
type: string | null;
fields: null;
};
}
interface UserActions {
action_field: string[];
new_value: string;
old_value: string;
}
export const caseMigrations = {
'7.10.0': (
doc: SavedObjectUnsanitizedDoc<UnsanitizedCase>
): SavedObjectSanitizedDoc<SanitizedCase> => {
const { connector_id, ...attributesWithoutConnectorId } = doc.attributes;
return {
...doc,
attributes: {
...attributesWithoutConnectorId,
connector: {
id: connector_id ?? 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
},
references: doc.references || [],
};
},
};
export const configureMigrations = {
'7.10.0': (
doc: SavedObjectUnsanitizedDoc<UnsanitizedConfigure>
): SavedObjectSanitizedDoc<SanitizedConfigure> => {
const { connector_id, connector_name, ...restAttributes } = doc.attributes;
return {
...doc,
attributes: {
...restAttributes,
connector: {
id: connector_id ?? 'none',
name: connector_name ?? 'none',
type: ConnectorTypes.none,
fields: null,
},
},
references: doc.references || [],
};
},
};
export const userActionsMigrations = {
'7.10.0': (doc: SavedObjectUnsanitizedDoc<UserActions>): SavedObjectSanitizedDoc<UserActions> => {
const { action_field, new_value, old_value, ...restAttributes } = doc.attributes;
if (
action_field == null ||
!Array.isArray(action_field) ||
action_field[0] !== 'connector_id'
) {
return { ...doc, references: doc.references || [] };
}
return {
...doc,
attributes: {
...restAttributes,
action_field: ['connector'],
new_value:
new_value != null
? JSON.stringify({
id: new_value,
name: 'none',
type: ConnectorTypes.none,
fields: null,
})
: new_value,
old_value:
old_value != null
? JSON.stringify({
id: old_value,
name: 'none',
type: ConnectorTypes.none,
fields: null,
})
: old_value,
},
references: doc.references || [],
};
},
};

View file

@ -5,6 +5,7 @@
*/
import { SavedObjectsType } from 'src/core/server';
import { userActionsMigrations } from './migrations';
export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions';
@ -44,4 +45,5 @@ export const caseUserActionSavedObjectType: SavedObjectsType = {
},
},
},
migrations: userActionsMigrations,
};

View file

@ -12,7 +12,7 @@ import {
SavedObjectsUpdateResponse,
} from 'kibana/server';
import { CasesConfigureAttributes, SavedObjectFindOptions } from '../../../common/api';
import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common/api';
import { CASE_CONFIGURE_SAVED_OBJECT } from '../../saved_object_types';
interface ClientArgs {
@ -27,22 +27,22 @@ interface FindCaseConfigureArgs extends ClientArgs {
}
interface PostCaseConfigureArgs extends ClientArgs {
attributes: CasesConfigureAttributes;
attributes: ESCasesConfigureAttributes;
}
interface PatchCaseConfigureArgs extends ClientArgs {
caseConfigureId: string;
updatedAttributes: Partial<CasesConfigureAttributes>;
updatedAttributes: Partial<ESCasesConfigureAttributes>;
}
export interface CaseConfigureServiceSetup {
delete(args: GetCaseConfigureArgs): Promise<{}>;
get(args: GetCaseConfigureArgs): Promise<SavedObject<CasesConfigureAttributes>>;
find(args: FindCaseConfigureArgs): Promise<SavedObjectsFindResponse<CasesConfigureAttributes>>;
get(args: GetCaseConfigureArgs): Promise<SavedObject<ESCasesConfigureAttributes>>;
find(args: FindCaseConfigureArgs): Promise<SavedObjectsFindResponse<ESCasesConfigureAttributes>>;
patch(
args: PatchCaseConfigureArgs
): Promise<SavedObjectsUpdateResponse<CasesConfigureAttributes>>;
post(args: PostCaseConfigureArgs): Promise<SavedObject<CasesConfigureAttributes>>;
): Promise<SavedObjectsUpdateResponse<ESCasesConfigureAttributes>>;
post(args: PostCaseConfigureArgs): Promise<SavedObject<ESCasesConfigureAttributes>>;
}
export class CaseConfigureService {

View file

@ -18,7 +18,12 @@ import {
} from 'kibana/server';
import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server';
import { CaseAttributes, CommentAttributes, SavedObjectFindOptions, User } from '../../common/api';
import {
ESCaseAttributes,
CommentAttributes,
SavedObjectFindOptions,
User,
} from '../../common/api';
import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types';
import { readReporters } from './reporters/read_reporters';
import { readTags } from './tags/read_tags';
@ -55,7 +60,7 @@ interface GetCommentArgs extends ClientArgs {
}
interface PostCaseArgs extends ClientArgs {
attributes: CaseAttributes;
attributes: ESCaseAttributes;
}
interface PostCommentArgs extends ClientArgs {
@ -65,7 +70,7 @@ interface PostCommentArgs extends ClientArgs {
interface PatchCase {
caseId: string;
updatedAttributes: Partial<CaseAttributes & PushedArgs>;
updatedAttributes: Partial<ESCaseAttributes & PushedArgs>;
version?: string;
}
type PatchCaseArgs = PatchCase & ClientArgs;
@ -100,18 +105,18 @@ interface CaseServiceDeps {
export interface CaseServiceSetup {
deleteCase(args: GetCaseArgs): Promise<{}>;
deleteComment(args: GetCommentArgs): Promise<{}>;
findCases(args: FindCasesArgs): Promise<SavedObjectsFindResponse<CaseAttributes>>;
findCases(args: FindCasesArgs): Promise<SavedObjectsFindResponse<ESCaseAttributes>>;
getAllCaseComments(args: FindCommentsArgs): Promise<SavedObjectsFindResponse<CommentAttributes>>;
getCase(args: GetCaseArgs): Promise<SavedObject<CaseAttributes>>;
getCases(args: GetCasesArgs): Promise<SavedObjectsBulkResponse<CaseAttributes>>;
getCase(args: GetCaseArgs): Promise<SavedObject<ESCaseAttributes>>;
getCases(args: GetCasesArgs): Promise<SavedObjectsBulkResponse<ESCaseAttributes>>;
getComment(args: GetCommentArgs): Promise<SavedObject<CommentAttributes>>;
getTags(args: ClientArgs): Promise<string[]>;
getReporters(args: ClientArgs): Promise<User[]>;
getUser(args: GetUserArgs): Promise<AuthenticatedUser | User>;
postNewCase(args: PostCaseArgs): Promise<SavedObject<CaseAttributes>>;
postNewCase(args: PostCaseArgs): Promise<SavedObject<ESCaseAttributes>>;
postNewComment(args: PostCommentArgs): Promise<SavedObject<CommentAttributes>>;
patchCase(args: PatchCaseArgs): Promise<SavedObjectsUpdateResponse<CaseAttributes>>;
patchCases(args: PatchCasesArgs): Promise<SavedObjectsBulkUpdateResponse<CaseAttributes>>;
patchCase(args: PatchCaseArgs): Promise<SavedObjectsUpdateResponse<ESCaseAttributes>>;
patchCases(args: PatchCasesArgs): Promise<SavedObjectsBulkUpdateResponse<ESCaseAttributes>>;
patchComment(args: UpdateCommentArgs): Promise<SavedObjectsUpdateResponse<CommentAttributes>>;
patchComments(args: PatchComments): Promise<SavedObjectsBulkUpdateResponse<CommentAttributes>>;
}

View file

@ -5,16 +5,20 @@
*/
import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server';
import { get } from 'lodash';
import { get, isPlainObject, isString } from 'lodash';
import deepEqual from 'fast-deep-equal';
import {
CaseUserActionAttributes,
UserAction,
UserActionField,
CaseAttributes,
ESCaseAttributes,
User,
} from '../../../common/api';
import { isTwoArraysDifference } from '../../routes/api/cases/helpers';
import {
isTwoArraysDifference,
transformESConnectorToCaseConnector,
} from '../../routes/api/cases/helpers';
import { UserActionItem } from '.';
import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types';
@ -120,7 +124,7 @@ export const buildCaseUserActionItem = ({
const userActionFieldsAllowed: UserActionField = [
'comment',
'connector_id',
'connector',
'description',
'tags',
'title',
@ -135,8 +139,8 @@ export const buildCaseUserActions = ({
}: {
actionDate: string;
actionBy: User;
originalCases: Array<SavedObject<CaseAttributes>>;
updatedCases: Array<SavedObjectsUpdateResponse<CaseAttributes>>;
originalCases: Array<SavedObject<ESCaseAttributes>>;
updatedCases: Array<SavedObjectsUpdateResponse<ESCaseAttributes>>;
}): UserActionItem[] =>
updatedCases.reduce<UserActionItem[]>((acc, updatedItem) => {
const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id);
@ -145,37 +149,17 @@ export const buildCaseUserActions = ({
const updatedFields = Object.keys(updatedItem.attributes) as UserActionField;
updatedFields.forEach((field) => {
if (userActionFieldsAllowed.includes(field)) {
const origValue = get(originalItem, ['attributes', field]);
const updatedValue = get(updatedItem, ['attributes', field]);
const compareValues = isTwoArraysDifference(origValue, updatedValue);
if (compareValues != null) {
if (compareValues.addedItems.length > 0) {
userActions = [
...userActions,
buildCaseUserActionItem({
action: 'add',
actionAt: actionDate,
actionBy,
caseId: updatedItem.id,
fields: [field],
newValue: compareValues.addedItems.join(', '),
}),
];
}
if (compareValues.deletedItems.length > 0) {
userActions = [
...userActions,
buildCaseUserActionItem({
action: 'delete',
actionAt: actionDate,
actionBy,
caseId: updatedItem.id,
fields: [field],
newValue: compareValues.deletedItems.join(', '),
}),
];
}
} else if (origValue !== updatedValue) {
const origValue =
field === 'connector' && originalItem.attributes.connector
? transformESConnectorToCaseConnector(originalItem.attributes.connector)
: get(originalItem, ['attributes', field]);
const updatedValue =
field === 'connector' && updatedItem.attributes.connector
? transformESConnectorToCaseConnector(updatedItem.attributes.connector)
: get(updatedItem, ['attributes', field]);
if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) {
userActions = [
...userActions,
buildCaseUserActionItem({
@ -188,6 +172,52 @@ export const buildCaseUserActions = ({
oldValue: origValue,
}),
];
} else if (Array.isArray(origValue) && Array.isArray(updatedValue)) {
const compareValues = isTwoArraysDifference(origValue, updatedValue);
if (compareValues && compareValues.addedItems.length > 0) {
userActions = [
...userActions,
buildCaseUserActionItem({
action: 'add',
actionAt: actionDate,
actionBy,
caseId: updatedItem.id,
fields: [field],
newValue: compareValues.addedItems.join(', '),
}),
];
}
if (compareValues && compareValues.deletedItems.length > 0) {
userActions = [
...userActions,
buildCaseUserActionItem({
action: 'delete',
actionAt: actionDate,
actionBy,
caseId: updatedItem.id,
fields: [field],
newValue: compareValues.deletedItems.join(', '),
}),
];
}
} else if (
isPlainObject(origValue) &&
isPlainObject(updatedValue) &&
!deepEqual(origValue, updatedValue)
) {
userActions = [
...userActions,
buildCaseUserActionItem({
action: 'update',
actionAt: actionDate,
actionBy,
caseId: updatedItem.id,
fields: [field],
newValue: JSON.stringify(updatedValue),
oldValue: JSON.stringify(origValue),
}),
];
}
}
});

View file

@ -15,12 +15,13 @@ import { TestProviders } from '../../../common/mock';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCase } from '../../containers/use_get_case';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { waitFor } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_user_actions');
@ -37,7 +38,15 @@ const usePostPushToServiceMock = usePostPushToService as jest.Mock;
export const caseProps: CaseProps = {
caseId: basicCase.id,
userCanCrud: true,
caseData: { ...basicCase, connectorId: 'servicenow-2' },
caseData: {
...basicCase,
connector: {
id: 'resilient-2',
name: 'Resilient',
type: ConnectorTypes.resilient,
fields: null,
},
},
fetchCase: jest.fn(),
updateCase: jest.fn(),
};
@ -275,7 +284,8 @@ describe('CaseView ', () => {
.first()
.exists()
).toBeTruthy();
expect(wrapper.find('[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy();
expect(wrapper.find('button[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy();
});
});
@ -442,34 +452,108 @@ describe('CaseView ', () => {
).toBeTruthy();
});
});
it('should revert to the initial connector in case of failure', async () => {
// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should revert to the initial connector in case of failure', async () => {
updateCaseProperty.mockImplementation(({ onError }) => {
onError();
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, connectorId: 'servicenow-1' }}
caseData={{
...caseProps.caseData,
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.servicenow,
fields: null,
},
}}
/>
</Router>
</TestProviders>
);
const connectorName = wrapper
.find('[data-test-subj="settings-connector-card"] .euiTitle')
.first()
.text();
await waitFor(() => {
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
});
await waitFor(() => {
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(
wrapper.find('[data-test-subj="dropdown-connectors"]').at(0).prop('valueOfSelected')
).toBe('servicenow-1');
wrapper.find('[data-test-subj="settings-connector-card"] .euiTitle').first().text()
).toBe(connectorName);
});
});
// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should update connector', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent
{...caseProps}
caseData={{
...caseProps.caseData,
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.servicenow,
fields: null,
},
}}
/>
</Router>
</TestProviders>
);
await waitFor(() => {
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
});
await waitFor(() => {
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
});
act(() => {
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
});
await waitFor(() => {
wrapper.update();
});
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(updateObject.updateValue).toEqual({
id: 'resilient-2',
name: 'My Connector 2',
type: ConnectorTypes.resilient,
fields: {
incidentTypes: null,
severityCode: null,
},
});
});
});

View file

@ -14,10 +14,11 @@ import {
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import * as i18n from './translations';
import { Case } from '../../containers/types';
import { getCaseUrl } from '../../../common/components/link_to';
import { Case, CaseConnector } from '../../containers/types';
import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to';
import { gutterTimeline } from '../../../common/lib/helpers';
import { HeaderPage } from '../../../common/components/header_page';
import { EditableTitle } from '../../../common/components/header_page/editable_title';
@ -26,18 +27,20 @@ import { useGetCase } from '../../containers/use_get_case';
import { UserActionTree } from '../user_action_tree';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search';
import { getTypedPayload } from '../../containers/utils';
import { WhitePageWrapper, HeaderWrapper } from '../wrappers';
import { useBasePath } from '../../../common/lib/kibana';
import { CaseStatus } from '../case_status';
import { navTabs } from '../../../app/home/home_navigations';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { usePushToService } from '../use_push_to_service';
import { EditConnector } from '../edit_connector';
import { useConnectors } from '../../containers/configure/use_connectors';
import { SecurityPageName } from '../../../app/types';
import {
getConnectorById,
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
interface Props {
caseId: string;
@ -77,10 +80,11 @@ export interface CaseProps extends Props {
export const CaseComponent = React.memo<CaseProps>(
({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => {
const basePath = window.location.origin + useBasePath();
const caseLink = `${basePath}/app/security/cases/${caseId}`;
const search = useGetUrlSearch(navTabs.case);
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
const allCasesLink = getCaseUrl(search);
const caseDetailsLink = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true });
const [initLoadingData, setInitLoadingData] = useState(true);
const {
caseUserActions,
fetchCaseUserActions,
@ -88,7 +92,8 @@ export const CaseComponent = React.memo<CaseProps>(
hasDataToPush,
isLoading: isLoadingUserActions,
participants,
} = useGetCaseUserActions(caseId, caseData.connectorId);
} = useGetCaseUserActions(caseId, caseData.connector.id);
const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({
caseId,
});
@ -113,13 +118,13 @@ export const CaseComponent = React.memo<CaseProps>(
});
}
break;
case 'connectorId':
const connectorId = getTypedPayload<string>(value);
if (connectorId.length > 0) {
case 'connector':
const connector = getTypedPayload<CaseConnector>(value);
if (connector != null) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'connector_id',
updateValue: connectorId,
updateKey: 'connector',
updateValue: connector,
updateCase: handleUpdateNewCase,
version: caseData.version,
onSuccess,
@ -172,6 +177,7 @@ export const CaseComponent = React.memo<CaseProps>(
},
[fetchCaseUserActions, updateCaseProperty, updateCase, caseData]
);
const handleUpdateCase = useCallback(
(newCase: Case) => {
updateCase(newCase);
@ -182,22 +188,24 @@ export const CaseComponent = React.memo<CaseProps>(
const { loading: isLoadingConnectors, connectors } = useConnectors();
const [caseConnectorName, isValidConnector] = useMemo(() => {
const connector = connectors.find((c) => c.id === caseData.connectorId);
return [connector?.name ?? 'none', !!connector];
}, [connectors, caseData.connectorId]);
const [connectorName, isValidConnector] = useMemo(() => {
const connector = connectors.find((c) => c.id === caseData.connector.id);
return [connector?.name ?? '', !!connector];
}, [connectors, caseData.connector]);
const currentExternalIncident = useMemo(
() =>
caseServices != null && caseServices[caseData.connectorId] != null
? caseServices[caseData.connectorId]
caseServices != null && caseServices[caseData.connector.id] != null
? caseServices[caseData.connector.id]
: null,
[caseServices, caseData.connectorId]
[caseServices, caseData.connector]
);
const { pushButton, pushCallouts } = usePushToService({
caseConnectorId: caseData.connectorId,
caseConnectorName,
connector: {
...caseData.connector,
name: isEmpty(caseData.connector.name) ? connectorName : caseData.connector.name,
},
caseServices,
caseId: caseData.id,
caseStatus: caseData.status,
@ -208,22 +216,31 @@ export const CaseComponent = React.memo<CaseProps>(
});
const onSubmitConnector = useCallback(
(connectorId, onSuccess, onError) =>
(connectorId, connectorFields, onError, onSuccess) => {
const connector = getConnectorById(connectorId, connectors);
const connectorToUpdate = connector
? normalizeActionConnector(connector)
: getNoneConnector();
onUpdateField({
key: 'connectorId',
value: connectorId,
key: 'connector',
value: { ...connectorToUpdate, fields: connectorFields },
onSuccess,
onError,
}),
[onUpdateField]
});
},
[onUpdateField, connectors]
);
const onSubmitTags = useCallback((newTags) => onUpdateField({ key: 'tags', value: newTags }), [
onUpdateField,
]);
const onSubmitTitle = useCallback(
(newTitle) => onUpdateField({ key: 'title', value: newTitle }),
[onUpdateField]
);
const toggleStatusCase = useCallback(
(e) =>
onUpdateField({
@ -232,6 +249,7 @@ export const CaseComponent = React.memo<CaseProps>(
}),
[onUpdateField]
);
const handleRefresh = useCallback(() => {
fetchCaseUserActions(caseData.id);
fetchCase();
@ -264,12 +282,13 @@ export const CaseComponent = React.memo<CaseProps>(
},
[caseData.closedAt, caseData.createdAt, caseData.status]
);
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(caseLink),
body: i18n.EMAIL_BODY(caseDetailsLink),
}),
[caseLink, caseData.title]
[caseDetailsLink, caseData.title]
);
useEffect(() => {
@ -280,12 +299,12 @@ export const CaseComponent = React.memo<CaseProps>(
const backOptions = useMemo(
() => ({
href: getCaseUrl(search),
href: allCasesLink,
text: i18n.BACK_TO_ALL,
dataTestSubj: 'backToCases',
pageId: SecurityPageName.case,
}),
[search]
[allCasesLink]
);
return (
@ -380,10 +399,13 @@ export const CaseComponent = React.memo<CaseProps>(
isLoading={isLoading && updateKey === 'tags'}
/>
<EditConnector
isLoading={isLoadingConnectors}
onSubmit={onSubmitConnector}
caseFields={caseData.connector.fields}
connectors={connectors}
selectedConnector={caseData.connectorId}
disabled={!userCanCrud}
isLoading={isLoadingConnectors || (isLoading && updateKey === 'connector')}
onSubmit={onSubmitConnector}
selectedConnector={caseData.connector.id}
userActions={caseUserActions}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -151,6 +151,14 @@ export const EMAIL_BODY = (caseUrl: string) =>
values: { caseUrl },
defaultMessage: 'Case reference: {caseUrl}',
});
export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', {
defaultMessage: 'Unknown',
});
export const CHANGED_CONNECTOR_FIELD = i18n.translate(
'xpack.securitySolution.case.caseView.fieldChanged',
{
defaultMessage: `changed connector field`,
}
);

View file

@ -4,13 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Connector } from '../../../containers/configure/types';
import { ReturnConnectors } from '../../../containers/configure/use_connectors';
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 const connectors: Connector[] = connectorsMock;
export const connectors: ActionConnector[] = connectorsMock;
// x - pack / plugins / triggers_actions_ui;
export const searchURL =
@ -18,12 +19,20 @@ export const searchURL =
export const useCaseConfigureResponse: ReturnUseCaseConfigure = {
closureType: 'close-by-user',
connectorId: 'none',
connectorName: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
currentConfiguration: {
connectorId: 'none',
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
connectorName: 'none',
},
firstLoad: false,
loading: false,
@ -38,7 +47,7 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = {
version: '',
};
export const useConnectorsResponse: ReturnConnectors = {
export const useConnectorsResponse: UseConnectorsResponse = {
loading: false,
connectors,
refetchConnectors: jest.fn(),

View file

@ -58,10 +58,10 @@ describe('Connectors', () => {
test('the connector is changed successfully', () => {
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
expect(onChangeConnector).toHaveBeenCalled();
expect(onChangeConnector).toHaveBeenCalledWith('servicenow-2');
expect(onChangeConnector).toHaveBeenCalledWith('resilient-2');
});
test('the connector is changed successfully to none', () => {

View file

@ -18,7 +18,7 @@ import styled from 'styled-components';
import { ConnectorsDropdown } from './connectors_dropdown';
import * as i18n from './translations';
import { Connector } from '../../containers/configure/types';
import { ActionConnector } from '../../containers/configure/types';
const EuiFormRowExtended = styled(EuiFormRow)`
.euiFormRow__labelWrapper {
@ -29,7 +29,7 @@ const EuiFormRowExtended = styled(EuiFormRow)`
`;
export interface Props {
connectors: Connector[];
connectors: ActionConnector[];
disabled: boolean;
isLoading: boolean;
updateConnectorDisabled: boolean;

View file

@ -44,8 +44,8 @@ describe('ConnectorsDropdown', () => {
'data-test-subj': 'dropdown-connector-servicenow-1',
}),
expect.objectContaining({
value: 'servicenow-2',
'data-test-subj': 'dropdown-connector-servicenow-2',
value: 'resilient-2',
'data-test-subj': 'dropdown-connector-resilient-2',
}),
])
);

View file

@ -8,12 +8,12 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui';
import styled from 'styled-components';
import { Connector } from '../../containers/configure/types';
import { ActionConnector } from '../../containers/configure/types';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import * as i18n from './translations';
export interface Props {
connectors: Connector[];
connectors: ActionConnector[];
disabled: boolean;
isLoading: boolean;
onChange: (id: string) => void;
@ -96,13 +96,14 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({
return (
<EuiSuperSelect
aria-label={i18n.INCIDENT_MANAGEMENT_SYSTEM_LABEL}
data-test-subj="dropdown-connectors"
disabled={disabled}
fullWidth
isLoading={isLoading}
onChange={onChange}
options={connectorsAsOptions}
valueOfSelected={selectedConnector}
fullWidth
onChange={onChange}
data-test-subj="dropdown-connectors"
/>
);
};

View file

@ -25,6 +25,7 @@ import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search';
import { connectors, searchURL, useCaseConfigureResponse, useConnectorsResponse } from './__mock__';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
jest.mock('../../../common/lib/kibana');
jest.mock('../../containers/configure/use_connectors');
@ -90,11 +91,19 @@ describe('ConfigureCases', () => {
useCaseConfigureMock.mockImplementation(() => ({
...useCaseConfigureResponse,
closureType: 'close-by-user',
connectorId: 'not-id',
connectorName: 'unchanged',
connector: {
id: 'not-id',
name: 'unchanged',
type: ConnectorTypes.none,
fields: null,
},
currentConfiguration: {
connectorName: 'unchanged',
connectorId: 'not-id',
connector: {
id: 'not-id',
name: 'unchanged',
type: ConnectorTypes.none,
fields: null,
},
closureType: 'close-by-user',
},
}));
@ -126,11 +135,19 @@ describe('ConfigureCases', () => {
...useCaseConfigureResponse,
mapping: connectors[0].config.incidentConfiguration.mapping,
closureType: 'close-by-user',
connectorId: 'servicenow-1',
connectorName: 'unchanged',
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.servicenow,
fields: null,
},
currentConfiguration: {
connectorName: 'unchanged',
connectorId: 'servicenow-1',
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.servicenow,
fields: null,
},
closureType: 'close-by-user',
},
}));
@ -213,11 +230,19 @@ describe('ConfigureCases', () => {
...useCaseConfigureResponse,
mapping: connectors[1].config.incidentConfiguration.mapping,
closureType: 'close-by-user',
connectorId: 'servicenow-2',
connectorName: 'unchanged',
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.resilient,
fields: null,
},
currentConfiguration: {
connectorName: 'unchanged',
connectorId: 'servicenow-1',
connector: {
id: 'servicenow-1',
name: 'unchanged',
type: ConnectorTypes.servicenow,
fields: null,
},
closureType: 'close-by-user',
},
}));
@ -258,7 +283,12 @@ describe('ConfigureCases', () => {
beforeEach(() => {
useCaseConfigureMock.mockImplementation(() => ({
...useCaseConfigureResponse,
connectorId: 'servicenow-1',
connector: {
id: 'servicenow-1',
name: 'SN',
type: ConnectorTypes.servicenow,
fields: null,
},
persistLoading: true,
}));
@ -327,11 +357,19 @@ describe('ConfigureCases', () => {
...useCaseConfigureResponse,
mapping: connectors[0].config.incidentConfiguration.mapping,
closureType: 'close-by-user',
connectorId: 'servicenow-1',
connectorName: 'My connector',
connector: {
id: 'resilient-2',
name: 'My connector',
type: ConnectorTypes.resilient,
fields: null,
},
currentConfiguration: {
connectorName: 'My connector',
connectorId: 'My connector',
connector: {
id: 'My connector',
name: 'My connector',
type: ConnectorTypes.jira,
fields: null,
},
closureType: 'close-by-user',
},
persistCaseConfigure,
@ -345,13 +383,17 @@ describe('ConfigureCases', () => {
test('it submits the configuration correctly when changing connector', () => {
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
expect(persistCaseConfigure).toHaveBeenCalled();
expect(persistCaseConfigure).toHaveBeenCalledWith({
connectorId: 'servicenow-2',
connectorName: 'My Connector 2',
connector: {
id: 'resilient-2',
name: 'My Connector 2',
type: ConnectorTypes.resilient,
fields: null,
},
closureType: 'close-by-user',
});
});
@ -360,18 +402,28 @@ describe('ConfigureCases', () => {
useCaseConfigureMock
.mockImplementationOnce(() => ({
...useCaseConfigureResponse,
connectorId: 'servicenow-1',
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.servicenow,
fields: null,
},
}))
.mockImplementation(() => ({
...useCaseConfigureResponse,
connectorId: 'servicenow-2',
connector: {
id: 'resilient-2',
name: 'My connector 2',
type: ConnectorTypes.resilient,
fields: null,
},
}));
wrapper = mount(<ConfigureCases userCanCrud />, { wrappingComponent: TestProviders });
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
expect(
@ -393,11 +445,19 @@ describe('closure options', () => {
...useCaseConfigureResponse,
mapping: connectors[0].config.incidentConfiguration.mapping,
closureType: 'close-by-user',
connectorId: 'servicenow-1',
connectorName: 'My connector',
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.servicenow,
fields: null,
},
currentConfiguration: {
connectorName: 'My connector',
connectorId: 'My connector',
connector: {
id: 'My connector',
name: 'My connector',
type: ConnectorTypes.jira,
fields: null,
},
closureType: 'close-by-user',
},
persistCaseConfigure,
@ -414,8 +474,12 @@ describe('closure options', () => {
expect(persistCaseConfigure).toHaveBeenCalled();
expect(persistCaseConfigure).toHaveBeenCalledWith({
connectorId: 'servicenow-1',
connectorName: 'My Connector',
connector: {
id: 'servicenow-1',
name: 'My connector',
type: ConnectorTypes.servicenow,
fields: null,
},
closureType: 'close-by-pushing',
});
});
@ -427,11 +491,19 @@ describe('user interactions', () => {
...useCaseConfigureResponse,
mapping: connectors[1].config.incidentConfiguration.mapping,
closureType: 'close-by-user',
connectorId: 'servicenow-2',
connectorName: 'unchanged',
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.resilient,
fields: null,
},
currentConfiguration: {
connectorName: 'unchanged',
connectorId: 'servicenow-2',
connector: {
id: 'resilient-2',
name: 'unchanged',
type: ConnectorTypes.servicenow,
fields: null,
},
closureType: 'close-by-user',
},
}));

View file

@ -25,9 +25,15 @@ import { ClosureType } from '../../containers/configure/types';
import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { SectionWrapper } from '../wrappers';
import { Connectors } from './connectors';
import { ClosureOptions } from './closure_options';
import { SectionWrapper } from '../wrappers';
import {
getConnectorById,
getNoneConnector,
normalizeActionConnector,
normalizeCaseConnector,
} from './utils';
import * as i18n from './translations';
const FormWrapper = styled.div`
@ -65,12 +71,10 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
);
const {
connectorId,
connector,
closureType,
currentConfiguration,
loading: loadingCaseConfigure,
persistLoading,
version,
persistCaseConfigure,
setConnector,
setClosureType,
@ -83,7 +87,7 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
// eslint-disable-next-line react-hooks/exhaustive-deps
const reloadConnectors = useCallback(async () => refetchConnectors(), []);
const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure;
const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connectorId === 'none';
const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none';
const onClickUpdateConnector = useCallback(() => {
setEditFlyoutVisibility(true);
@ -93,16 +97,14 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
(isVisible: boolean) => {
setAddFlyoutVisibility(isVisible);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentConfiguration, connectorId, closureType]
[setAddFlyoutVisibility]
);
const handleSetEditFlyoutVisibility = useCallback(
(isVisible: boolean) => {
setEditFlyoutVisibility(isVisible);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentConfiguration, connectorId, closureType]
[setEditFlyoutVisibility]
);
const onChangeConnector = useCallback(
@ -112,54 +114,52 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
return;
}
setConnector(id);
const actionConnector = getConnectorById(id, connectors);
const caseConnector =
actionConnector != null ? normalizeActionConnector(actionConnector) : getNoneConnector();
setConnector(caseConnector);
persistCaseConfigure({
connectorId: id,
connectorName: connectors.find((c) => c.id === id)?.name ?? '',
connector: caseConnector,
closureType,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[connectorId, closureType, version]
[connectors, closureType, persistCaseConfigure, setConnector]
);
const onChangeClosureType = useCallback(
(type: ClosureType) => {
setClosureType(type);
persistCaseConfigure({
connectorId,
connectorName: connectors.find((c) => c.id === connectorId)?.name ?? '',
connector,
closureType: type,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[connectorId, closureType, version]
[connector, persistCaseConfigure, setClosureType]
);
useEffect(() => {
if (
!isLoadingConnectors &&
connectorId !== 'none' &&
!connectors.some((c) => c.id === connectorId)
connector.id !== 'none' &&
!connectors.some((c) => c.id === connector.id)
) {
setConnectorIsValid(false);
} else if (
!isLoadingConnectors &&
(connectorId === 'none' || connectors.some((c) => c.id === connectorId))
(connector.id === 'none' || connectors.some((c) => c.id === connector.id))
) {
setConnectorIsValid(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectors, connectorId]);
}, [connectors, connector, isLoadingConnectors]);
useEffect(() => {
if (!isLoadingConnectors && connectorId !== 'none') {
if (!isLoadingConnectors && connector.id !== 'none') {
setEditedConnectorItem(
connectors.find((c) => c.id === connectorId) as ActionConnectorTableItem
normalizeCaseConnector(connectors, connector) as ActionConnectorTableItem
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectors, connectorId]);
}, [connectors, connector, isLoadingConnectors]);
return (
<FormWrapper>
@ -190,7 +190,7 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC
onChangeConnector={onChangeConnector}
updateConnectorDisabled={updateConnectorDisabled || !userCanCrud}
handleShowEditFlyout={onClickUpdateConnector}
selectedConnector={connectorId}
selectedConnector={connector.id}
/>
</SectionWrapper>
<ActionsConnectorsContextProvider

View file

@ -6,6 +6,8 @@
import { i18n } from '@kbn/i18n';
export * from '../../translations';
export const INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate(
'xpack.securitySolution.case.configureCases.incidentManagementSystemTitle',
{
@ -28,13 +30,6 @@ export const INCIDENT_MANAGEMENT_SYSTEM_LABEL = i18n.translate(
}
);
export const NO_CONNECTOR = i18n.translate(
'xpack.securitySolution.case.configureCases.noConnector',
{
defaultMessage: 'No connector selected',
}
);
export const ADD_NEW_CONNECTOR = i18n.translate(
'xpack.securitySolution.case.configureCases.addNewConnector',
{

View file

@ -3,11 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ConnectorTypeFields, ConnectorTypes } from '../../../../../case/common/api';
import {
CaseField,
ActionType,
CasesConfigurationMapping,
ThirdPartyField,
ActionConnector,
CaseConnector,
} from '../../containers/configure/types';
export const setActionTypeToMapping = (
@ -41,3 +44,35 @@ export const setThirdPartyToMapping = (
}
return item;
});
export const getNoneConnector = (): CaseConnector => ({
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
});
export const getConnectorById = (
id: string,
connectors: ActionConnector[]
): ActionConnector | null => connectors.find((c) => c.id === id) ?? null;
export const normalizeActionConnector = (
actionConnector: ActionConnector,
fields: CaseConnector['fields'] = null
): CaseConnector => {
const caseConnectorFieldsType = {
type: actionConnector.actionTypeId,
fields,
} as ConnectorTypeFields;
return {
id: actionConnector.id,
name: actionConnector.name,
...caseConnectorFieldsType,
};
};
export const normalizeCaseConnector = (
connectors: ActionConnector[],
caseConnector: CaseConnector
): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null;

View file

@ -9,24 +9,26 @@ import React, { useCallback, useEffect } from 'react';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports';
import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown';
import { Connector } from '../../../../../case/common/api/cases';
import { ActionConnector } from '../../../../../case/common/api/cases';
interface ConnectorSelectorProps {
connectors: Connector[];
connectors: ActionConnector[];
dataTestSubj: string;
defaultValue?: ActionConnector;
disabled: boolean;
field: FieldHook;
idAria: string;
defaultValue?: string;
disabled: boolean;
isEdit: boolean;
isLoading: boolean;
}
export const ConnectorSelector = ({
connectors,
dataTestSubj,
defaultValue,
disabled = false,
field,
idAria,
disabled = false,
isEdit = true,
isLoading = false,
}: ConnectorSelectorProps) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
@ -37,13 +39,13 @@ export const ConnectorSelector = ({
}, [defaultValue]);
const handleContentChange = useCallback(
(newContent: string) => {
field.setValue(newContent);
(newConnector: string) => {
field.setValue(newConnector);
},
[field]
);
return (
return isEdit ? (
<EuiFormRow
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
@ -56,11 +58,11 @@ export const ConnectorSelector = ({
>
<ConnectorsDropdown
connectors={connectors}
selectedConnector={field.value as string}
disabled={disabled}
isLoading={isLoading}
onChange={handleContentChange}
selectedConnector={(field.value as string) ?? 'none'}
/>
</EuiFormRow>
);
) : null;
};

View file

@ -19,6 +19,9 @@ import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/fo
import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
import { waitFor } from '@testing-library/react';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
@ -40,6 +43,7 @@ jest.mock(
);
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_connectors');
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider',
() => ({
@ -47,7 +51,7 @@ jest.mock(
children({ tags: ['rad', 'dude'] }),
})
);
const useConnectorsMock = useConnectors as jest.Mock;
const useFormMock = useForm as jest.Mock;
const useFormDataMock = useFormData as jest.Mock;
@ -72,6 +76,12 @@ const sampleData = {
description: 'what a great description',
tags: sampleTags,
title: 'what a cool title',
connector: {
fields: null,
id: 'none',
name: 'none',
type: ConnectorTypes.none,
},
};
const defaultPostCase = {
isLoading: false,
@ -79,6 +89,7 @@ const defaultPostCase = {
caseData: null,
postCase,
};
const sampleConnectorData = { loading: false, connectors: [] };
describe('Create case', () => {
const fetchTags = jest.fn();
const formHookMock = getFormMock(sampleData);
@ -87,7 +98,12 @@ describe('Create case', () => {
useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline);
usePostCaseMock.mockImplementation(() => defaultPostCase);
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [{ description: sampleData.description }]);
useFormDataMock.mockImplementation(() => [
{
description: sampleData.description,
},
]);
useConnectorsMock.mockReturnValue(sampleConnectorData);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
(useGetTags as jest.Mock).mockImplementation(() => ({
tags: sampleTags,
@ -95,63 +111,122 @@ describe('Create case', () => {
}));
});
it('should post case on submit click', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => expect(postCase).toBeCalledWith(sampleData));
});
describe('Step 1 - Case Fields', () => {
it('should post case on submit click', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => expect(postCase).toBeCalledWith(sampleData));
});
it('should redirect to all cases on cancel click', () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click');
expect(mockHistory.push).toHaveBeenCalledWith('/');
});
it('should redirect to new case when caseData is there', () => {
const sampleId = '777777';
usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, caseData: { id: sampleId } }));
mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/777777');
});
it('should redirect to all cases on cancel click', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
wrapper.find(`[data-test-subj="create-case-cancel"]`).first().simulate('click');
await waitFor(() => expect(mockHistory.push).toHaveBeenCalledWith('/'));
});
it('should redirect to new case when caseData is there', async () => {
const sampleId = '777777';
usePostCaseMock.mockImplementation(() => ({
...defaultPostCase,
caseData: { id: sampleId },
}));
mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await waitFor(() => expect(mockHistory.push).toHaveBeenNthCalledWith(1, '/777777'));
});
it('should render spinner when loading', () => {
usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, isLoading: true }));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy();
it('should render spinner when loading', async () => {
usePostCaseMock.mockImplementation(() => ({ ...defaultPostCase, isLoading: true }));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await waitFor(() =>
expect(wrapper.find(`[data-test-subj="create-case-loading-spinner"]`).exists()).toBeTruthy()
);
});
it('Tag options render with new tags added', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await waitFor(() =>
expect(
wrapper
.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`)
.first()
.prop('options')
).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }])
);
});
});
it('Tag options render with new tags added', () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options')
).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]);
describe('Step 2 - Connector Fields', () => {
const connectorTypes = [
{
label: 'Jira',
testId: 'jira-1',
dataTestSubj: 'connector-settings-jira',
},
{
label: 'Resilient',
testId: 'resilient-2',
dataTestSubj: 'connector-settings-resilient',
},
{
label: 'ServiceNow',
testId: 'servicenow-1',
dataTestSubj: 'connector-settings-sn',
},
];
connectorTypes.forEach(({ label, testId, dataTestSubj }) => {
it(`should change from none to ${label} connector fields`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<Create />
</Router>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="${dataTestSubj}"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-${testId}"]`).simulate('click');
wrapper.update();
});
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="${dataTestSubj}"]`).exists()).toBeTruthy();
});
});
});
});
});

View file

@ -3,7 +3,7 @@
* 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, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
@ -11,44 +11,55 @@ import {
EuiFlexItem,
EuiLoadingSpinner,
EuiPanel,
EuiSteps,
} from '@elastic/eui';
import styled, { css } from 'styled-components';
import { useHistory } from 'react-router-dom';
import { isEqual } from 'lodash/fp';
import { CasePostRequest } from '../../../../../case/common/api';
import {
Field,
Form,
getUseField,
useForm,
UseField,
FormDataProvider,
getUseField,
UseField,
useForm,
useFormData,
} from '../../../shared_imports';
import { usePostCase } from '../../containers/use_post_case';
import { schema } from './schema';
import { schema, FormProps } from './schema';
import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import * as i18n from '../../translations';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form';
import { useGetTags } from '../../containers/use_get_tags';
import { getCaseDetailsUrl } from '../../../common/components/link_to';
import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click';
import { SettingFieldsForm } from '../settings/fields_form';
import { useConnectors } from '../../containers/configure/use_connectors';
import { ConnectorSelector } from '../connector_selector/form';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import {
normalizeCaseConnector,
getConnectorById,
getNoneConnector,
normalizeActionConnector,
} from '../configure_cases/utils';
import { ActionConnector } from '../../containers/types';
import { ConnectorFields } from '../../../../../case/common/api/connectors';
import * as i18n from './translations';
export const CommonUseField = getUseField({ component: Field });
const ContainerBig = styled.div`
${({ theme }) => css`
margin-top: ${theme.eui.euiSizeXL};
interface ContainerProps {
big?: boolean;
}
const Container = styled.div.attrs((props) => props)<ContainerProps>`
${({ big, theme }) => css`
margin-top: ${big ? theme.eui.euiSizeXL : theme.eui.euiSize};
`}
`;
const Container = styled.div`
${({ theme }) => css`
margin-top: ${theme.eui.euiSize};
`}
`;
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
top: 50%;
@ -56,32 +67,29 @@ const MySpinner = styled(EuiLoadingSpinner)`
z-index: 99;
`;
const initialCaseValue: CasePostRequest = {
const initialCaseValue: FormProps = {
description: '',
tags: [],
title: '',
connectorId: 'none',
};
export const Create = React.memo(() => {
const history = useHistory();
const { caseData, isLoading, postCase } = usePostCase();
const { form } = useForm<CasePostRequest>({
defaultValue: initialCaseValue,
options: { stripEmptyFields: false },
schema,
});
const fieldName = 'description';
const { submit, setFieldValue } = form;
const [{ description }] = useFormData({ form, watch: [fieldName] });
const { loading: isLoadingConnectors, connectors } = useConnectors();
const { connector: configureConnector, loading: isLoadingCaseConfigure } = useCaseConfigure();
const { tags: tagOptions } = useGetTags();
const [connector, setConnector] = useState<ActionConnector | null>(null);
const [options, setOptions] = useState(
tagOptions.map((label) => ({
label,
}))
);
// This values uses useEffect to update, not useMemo,
// because we need to setState on it from the jsx
useEffect(
() =>
setOptions(
@ -92,7 +100,39 @@ export const Create = React.memo(() => {
[tagOptions]
);
const onDescriptionChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [
const [fields, setFields] = useState<ConnectorFields>(null);
const { form } = useForm<FormProps>({
defaultValue: initialCaseValue,
options: { stripEmptyFields: false },
schema,
});
const currentConnectorId = useMemo(
() =>
!isLoadingCaseConfigure
? normalizeCaseConnector(connectors, configureConnector)?.id ?? 'none'
: null,
[configureConnector, connectors, isLoadingCaseConfigure]
);
const { submit, setFieldValue } = form;
const [{ description }] = useFormData<{
description: string;
}>({
form,
watch: ['description'],
});
const onChangeConnector = useCallback(
(newConnectorId) => {
if (connector == null || connector.id !== newConnectorId) {
setConnector(getConnectorById(newConnectorId, connectors) ?? null);
// Reset setting fields when changing connector
setFields(null);
}
},
[connector, connectors]
);
const onDescriptionChange = useCallback((newValue) => setFieldValue('description', newValue), [
setFieldValue,
]);
@ -106,15 +146,145 @@ export const Create = React.memo(() => {
const onSubmit = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
// `postCase`'s type is incorrect, it actually returns a promise
await postCase(data);
const { connectorId: dataConnectorId, ...dataWithoutConnectorId } = data;
const caseConnector = getConnectorById(dataConnectorId, connectors);
const connectorToUpdate = caseConnector
? normalizeActionConnector(caseConnector, fields)
: getNoneConnector();
await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate });
}
}, [submit, postCase]);
}, [submit, postCase, fields, connectors]);
const handleSetIsCancel = useCallback(() => {
history.push('/');
}, [history]);
const firstStep = useMemo(
() => ({
title: i18n.STEP_ONE_TITLE,
children: (
<>
<CommonUseField
path="title"
componentProps={{
idAria: 'caseTitle',
'data-test-subj': 'caseTitle',
euiFieldProps: {
fullWidth: false,
disabled: isLoading,
},
}}
/>
<Container>
<CommonUseField
path="tags"
componentProps={{
idAria: 'caseTags',
'data-test-subj': 'caseTags',
euiFieldProps: {
fullWidth: true,
placeholder: '',
disabled: isLoading,
options,
noSuggestions: false,
},
}}
/>
<FormDataProvider pathsToWatch="tags">
{({ tags: anotherTags }) => {
const current: string[] = options.map((opt) => opt.label);
const newOptions = anotherTags.reduce((acc: string[], item: string) => {
if (!acc.includes(item)) {
return [...acc, item];
}
return acc;
}, current);
if (!isEqual(current, newOptions)) {
setOptions(
newOptions.map((label: string) => ({
label,
}))
);
}
return null;
}}
</FormDataProvider>
</Container>
<Container big>
<UseField
path={'description'}
component={MarkdownEditorForm}
componentProps={{
dataTestSubj: 'caseDescription',
idAria: 'caseDescription',
isDisabled: isLoading,
onClickTimeline: handleTimelineClick,
onCursorPositionUpdate: handleCursorChange,
topRightContent: (
<InsertTimelinePopover
hideUntitled={true}
isDisabled={isLoading}
onTimelineChange={handleOnTimelineChange}
/>
),
}}
/>
</Container>
</>
),
}),
[isLoading, options, handleCursorChange, handleTimelineClick, handleOnTimelineChange]
);
const secondStep = useMemo(
() => ({
title: i18n.STEP_TWO_TITLE,
children: (
<EuiFlexGroup>
<EuiFlexItem>
<Container>
<UseField
path="connectorId"
component={ConnectorSelector}
componentProps={{
connectors,
dataTestSubj: 'caseConnectors',
defaultValue: currentConnectorId,
disabled: isLoadingConnectors,
idAria: 'caseConnectors',
isLoading,
}}
onChange={onChangeConnector}
/>
</Container>
</EuiFlexItem>
<EuiFlexItem>
<Container>
<SettingFieldsForm
connector={connector}
fields={fields}
isEdit={true}
onChange={setFields}
/>
</Container>
</EuiFlexItem>
</EuiFlexGroup>
),
}),
[
connector,
connectors,
currentConnectorId,
fields,
isLoading,
isLoadingConnectors,
onChangeConnector,
]
);
const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]);
if (caseData != null && caseData.id) {
history.push(getCaseDetailsUrl({ id: caseData.id }));
return null;
@ -124,72 +294,7 @@ export const Create = React.memo(() => {
<EuiPanel>
{isLoading && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />}
<Form form={form}>
<CommonUseField
path="title"
componentProps={{
idAria: 'caseTitle',
'data-test-subj': 'caseTitle',
euiFieldProps: {
fullWidth: false,
disabled: isLoading,
},
}}
/>
<Container>
<CommonUseField
path="tags"
componentProps={{
idAria: 'caseTags',
'data-test-subj': 'caseTags',
euiFieldProps: {
fullWidth: true,
placeholder: '',
disabled: isLoading,
options,
noSuggestions: false,
},
}}
/>
</Container>
<ContainerBig>
<UseField
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
dataTestSubj: 'caseDescription',
idAria: 'caseDescription',
isDisabled: isLoading,
onClickTimeline: handleTimelineClick,
onCursorPositionUpdate: handleCursorChange,
topRightContent: (
<InsertTimelinePopover
hideUntitled={true}
isDisabled={isLoading}
onTimelineChange={handleOnTimelineChange}
/>
),
}}
/>
</ContainerBig>
<FormDataProvider pathsToWatch="tags">
{({ tags: anotherTags }) => {
const current: string[] = options.map((opt) => opt.label);
const newOptions = anotherTags.reduce((acc: string[], item: string) => {
if (!acc.includes(item)) {
return [...acc, item];
}
return acc;
}, current);
if (!isEqual(current, newOptions)) {
setOptions(
newOptions.map((label: string) => ({
label,
}))
);
}
return null;
}}
</FormDataProvider>
<EuiSteps headingElement="h2" steps={allSteps} />
</Form>
<Container>
<EuiFlexGroup

View file

@ -18,7 +18,9 @@ export const schemaTags = {
labelAppend: OptionalFieldLabel,
};
export const schema: FormSchema<CasePostRequest> = {
export type FormProps = Omit<CasePostRequest, 'connector'> & { connectorId: string };
export const schema: FormSchema<FormProps> = {
title: {
type: FIELD_TYPES.TEXT,
label: i18n.NAME,
@ -37,4 +39,9 @@ export const schema: FormSchema<CasePostRequest> = {
],
},
tags: schemaTags,
connectorId: {
type: FIELD_TYPES.SUPER_SELECT,
label: i18n.CONNECTORS,
defaultValue: 'none',
},
};

View file

@ -0,0 +1,23 @@
/*
* 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 * from '../../translations';
export const STEP_ONE_TITLE = i18n.translate(
'xpack.securitySolution.components.create.stepOneTitle',
{
defaultMessage: 'Case fields',
}
);
export const STEP_TWO_TITLE = i18n.translate(
'xpack.securitySolution.components.create.stepTwoTitle',
{
defaultMessage: 'External incident management system fields',
}
);

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 { CaseUserActions } from '../../containers/types';
export const getConnectorFieldsFromUserActions = (id: string, userActions: CaseUserActions[]) => {
try {
for (const action of [...userActions].reverse()) {
if (action.actionField.length === 1 && action.actionField[0] === 'connector') {
if (action.oldValue && action.newValue) {
const oldValue = JSON.parse(action.oldValue);
const newValue = JSON.parse(action.newValue);
if (newValue.id === id) {
return newValue.fields;
}
if (oldValue.id === id) {
return oldValue.fields;
}
}
}
}
return null;
} catch {
return null;
}
};

View file

@ -12,10 +12,12 @@ import { getFormMock, useFormMock } from '../__mock__/form';
import { TestProviders } from '../../../common/mock';
import { connectorsMock } from '../../containers/configure/mock';
import { waitFor } from '@testing-library/react';
import { caseUserActions } from '../../containers/mock';
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
const onSubmit = jest.fn();
const defaultProps = {
connectors: connectorsMock,
@ -23,22 +25,26 @@ const defaultProps = {
isLoading: false,
onSubmit,
selectedConnector: 'none',
caseFields: null,
userActions: caseUserActions,
};
describe('EditConnector ', () => {
const sampleConnector = '123';
const formHookMock = getFormMock({ connector: sampleConnector });
const formHookMock = getFormMock({ connectorId: sampleConnector });
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
useFormMock.mockImplementation(() => ({ form: formHookMock }));
});
it('Renders no connector, and then edit', () => {
it('Renders no connector, and then edit', async () => {
const wrapper = mount(
<TestProviders>
<EditConnector {...defaultProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
expect(
wrapper.find(`span[data-test-subj="dropdown-connector-no-connector"]`).last().exists()
@ -46,8 +52,8 @@ describe('EditConnector ', () => {
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => wrapper.update());
expect(wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().exists()).toBeTruthy();
});
@ -58,10 +64,11 @@ describe('EditConnector ', () => {
<EditConnector {...defaultProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
expect(wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().exists()).toBeTruthy();
@ -79,10 +86,11 @@ describe('EditConnector ', () => {
<EditConnector {...defaultProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
expect(wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().exists()).toBeTruthy();
@ -90,7 +98,7 @@ describe('EditConnector ', () => {
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
await waitFor(() => {
wrapper.update();
expect(formHookMock.setFieldValue).toHaveBeenCalledWith('connector', 'none');
expect(formHookMock.setFieldValue).toHaveBeenCalledWith('connectorId', 'none');
});
});
@ -103,29 +111,32 @@ describe('EditConnector ', () => {
<EditConnector {...props} />
</TestProviders>
);
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="dropdown-connector-servicenow-2"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
wrapper.update();
wrapper.find(`[data-test-subj="edit-connectors-cancel"]`).last().simulate('click');
await waitFor(() => {
wrapper.update();
expect(formHookMock.setFieldValue).toBeCalledWith(
'connector',
'connectorId',
defaultProps.selectedConnector
);
});
});
it('Renders loading spinner', () => {
it('Renders loading spinner', async () => {
const props = { ...defaultProps, isLoading: true };
const wrapper = mount(
<TestProviders>
<EditConnector {...props} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy();
await waitFor(() =>
expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy()
);
});
});

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useState } from 'react';
import React, { useCallback, useReducer } from 'react';
import deepEqual from 'fast-deep-equal';
import {
EuiText,
EuiHorizontalRule,
@ -13,80 +14,192 @@ import {
EuiButton,
EuiButtonEmpty,
EuiLoadingSpinner,
EuiButtonIcon,
} from '@elastic/eui';
import styled, { css } from 'styled-components';
import styled from 'styled-components';
import { noop } from 'lodash/fp';
import * as i18n from '../../translations';
import { Form, UseField, useForm } from '../../../shared_imports';
import { schema } from './schema';
import { ConnectorTypeFields } from '../../../../../case/common/api/connectors';
import { ConnectorSelector } from '../connector_selector/form';
import { Connector } from '../../../../../case/common/api/cases';
import { ActionConnector } from '../../../../../case/common/api/cases';
import { SettingFieldsForm } from '../settings/fields_form';
import { getConnectorById } from '../configure_cases/utils';
import { CaseUserActions } from '../../containers/types';
import { schema } from './schema';
import { getConnectorFieldsFromUserActions } from './helpers';
import * as i18n from './translations';
interface EditConnectorProps {
connectors: Connector[];
caseFields: ConnectorTypeFields['fields'];
connectors: ActionConnector[];
disabled?: boolean;
isLoading: boolean;
onSubmit: (a: string, onSuccess: () => void, onError: () => void) => void;
onSubmit: (
connectorId: string,
connectorFields: ConnectorTypeFields['fields'],
onError: () => void,
onSuccess: () => void
) => void;
selectedConnector: string;
userActions: CaseUserActions[];
}
const MyFlexGroup = styled(EuiFlexGroup)`
${({ theme }) => css`
${({ theme }) => `
margin-top: ${theme.eui.euiSizeM};
p {
font-size: ${theme.eui.euiSizeM};
}
`}
`;
const DisappearingFlexItem = styled(EuiFlexItem)`
${({ $isHidden }: { $isHidden: boolean }) =>
$isHidden &&
`
margin: 0 !important;
`}
`;
interface State {
currentConnector: ActionConnector | null;
fields: ConnectorTypeFields['fields'];
editConnector: boolean;
}
type Action =
| { type: 'SET_CURRENT_CONNECTOR'; payload: State['currentConnector'] }
| { type: 'SET_FIELDS'; payload: State['fields'] }
| { type: 'SET_EDIT_CONNECTOR'; payload: State['editConnector'] };
const editConnectorReducer = (state: State, action: Action) => {
switch (action.type) {
case 'SET_CURRENT_CONNECTOR':
return {
...state,
currentConnector: action.payload,
};
case 'SET_FIELDS':
return {
...state,
fields: action.payload,
};
case 'SET_EDIT_CONNECTOR':
return {
...state,
editConnector: action.payload,
};
default:
return state;
}
};
const initialState = {
currentConnector: null,
fields: null,
editConnector: false,
};
export const EditConnector = React.memo(
({
caseFields,
connectors,
disabled = false,
isLoading,
onSubmit,
selectedConnector,
userActions,
}: EditConnectorProps) => {
const initialState: {
connectors: Connector[];
connector: string | undefined;
} = {
connectors,
connector: undefined,
};
const { form } = useForm({
defaultValue: initialState,
defaultValue: { connectorId: selectedConnector },
options: { stripEmptyFields: false },
schema,
});
const { setFieldValue, submit } = form;
const [connectorHasChanged, setConnectorHasChanged] = useState(false);
const [{ currentConnector, fields, editConnector }, dispatch] = useReducer(
editConnectorReducer,
{ ...initialState, fields: caseFields }
);
const onChangeConnector = useCallback(
(connectorId) => {
setConnectorHasChanged(selectedConnector !== connectorId);
(newConnectorId) => {
// Init
if (currentConnector == null) {
dispatch({
type: 'SET_CURRENT_CONNECTOR',
payload: getConnectorById(newConnectorId, connectors),
});
}
// change connect on dropdown action
else if (currentConnector.id !== newConnectorId) {
dispatch({
type: 'SET_CURRENT_CONNECTOR',
payload: getConnectorById(newConnectorId, connectors),
});
dispatch({
type: 'SET_FIELDS',
payload: getConnectorFieldsFromUserActions(newConnectorId, userActions ?? []),
});
} else if (fields === null) {
dispatch({
type: 'SET_FIELDS',
payload: getConnectorFieldsFromUserActions(newConnectorId, userActions ?? []),
});
}
},
[selectedConnector]
[currentConnector, fields, userActions, connectors]
);
const onFieldsChange = useCallback(
(newFields) => {
if (!deepEqual(newFields, fields)) {
dispatch({
type: 'SET_FIELDS',
payload: newFields,
});
}
},
[fields, dispatch]
);
const onError = useCallback(() => {
setFieldValue('connector', selectedConnector);
setConnectorHasChanged(false);
}, [setFieldValue, selectedConnector]);
setFieldValue('connectorId', selectedConnector);
dispatch({
type: 'SET_EDIT_CONNECTOR',
payload: false,
});
}, [dispatch, setFieldValue, selectedConnector]);
const onCancelConnector = useCallback(() => {
setFieldValue('connector', selectedConnector);
setConnectorHasChanged(false);
}, [selectedConnector, setFieldValue]);
setFieldValue('connectorId', selectedConnector);
dispatch({
type: 'SET_FIELDS',
payload: caseFields,
});
dispatch({
type: 'SET_EDIT_CONNECTOR',
payload: false,
});
}, [dispatch, selectedConnector, setFieldValue, caseFields]);
const onSubmitConnector = useCallback(async () => {
const { isValid, data: newData } = await submit();
if (isValid && newData.connector) {
onSubmit(newData.connector, noop, onError);
setConnectorHasChanged(false);
if (isValid && newData.connectorId) {
onSubmit(newData.connectorId, fields, onError, noop);
dispatch({
type: 'SET_EDIT_CONNECTOR',
payload: false,
});
}
}, [submit, onSubmit, onError]);
}, [dispatch, submit, fields, onSubmit, onError]);
const onEditClick = useCallback(() => {
dispatch({
type: 'SET_EDIT_CONNECTOR',
payload: true,
});
}, [dispatch]);
return (
<EuiText>
<MyFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween">
@ -94,32 +207,59 @@ export const EditConnector = React.memo(
<h4>{i18n.CONNECTORS}</h4>
</EuiFlexItem>
{isLoading && <EuiLoadingSpinner data-test-subj="connector-loading" />}
{!isLoading && !editConnector && (
<EuiFlexItem data-test-subj="connector-edit" grow={false}>
<EuiButtonIcon
data-test-subj="connector-edit-button"
isDisabled={disabled}
aria-label={i18n.EDIT_CONNECTOR_ARIA}
iconType={'pencil'}
onClick={onEditClick}
/>
</EuiFlexItem>
)}
</MyFlexGroup>
<EuiHorizontalRule margin="xs" />
<MyFlexGroup gutterSize="xs">
<MyFlexGroup gutterSize="none">
<EuiFlexGroup data-test-subj="edit-connectors" direction="column">
<EuiFlexItem>
<DisappearingFlexItem $isHidden={!editConnector}>
<Form form={form}>
<EuiFlexGroup gutterSize="none" direction="row">
<EuiFlexItem>
<UseField
path="connector"
path="connectorId"
component={ConnectorSelector}
componentProps={{
connectors,
dataTestSubj: 'caseConnectors',
idAria: 'caseConnectors',
isLoading,
disabled,
defaultValue: selectedConnector,
disabled,
idAria: 'caseConnectors',
isEdit: editConnector,
isLoading,
}}
onChange={onChangeConnector}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
</DisappearingFlexItem>
<EuiFlexItem data-test-subj="edit-connector-settings-fields-form-flex-item">
{(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined.
!(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted.
!editConnector && (
<EuiText size="s">
<span>{i18n.NO_CONNECTOR}</span>
</EuiText>
)}
<SettingFieldsForm
connector={currentConnector}
fields={fields}
isEdit={editConnector}
onChange={onFieldsChange}
/>
</EuiFlexItem>
{connectorHasChanged && (
{editConnector && (
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>

View file

@ -3,10 +3,15 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormSchema } from '../../../shared_imports';
export const schema: FormSchema = {
connector: {
defaultValue: 'none',
import { FormSchema, FIELD_TYPES } from '../../../shared_imports';
export interface FormProps {
connectorId: string;
}
export const schema: FormSchema<FormProps> = {
connectorId: {
type: FIELD_TYPES.SUPER_SELECT,
},
};

View file

@ -0,0 +1,16 @@
/*
* 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 * from '../../translations';
export const EDIT_CONNECTOR_ARIA = i18n.translate(
'xpack.securitySolution.case.editConnector.editConnectorLinkAria',
{
defaultMessage: 'click to edit connector',
}
);

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 React, { memo, useMemo } from 'react';
import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui';
import styled from 'styled-components';
import { connectorsConfiguration } from '../../../common/lib/connectors/config';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
interface ConnectorCardProps {
connectorType: ConnectorTypes;
title: string;
listItems: Array<{ title: string; description: React.ReactNode }>;
isLoading: boolean;
}
const StyledText = styled.span`
span {
display: block;
}
`;
const ConnectorCardDisplay: React.FC<ConnectorCardProps> = ({
connectorType,
title,
listItems,
isLoading,
}) => {
const description = useMemo(
() => (
<StyledText>
{listItems.length > 0 &&
listItems.map((item, i) => (
<span key={`${item.title}-${i}`}>
<strong>{`${item.title}: `}</strong>
{item.description}
</span>
))}
</StyledText>
),
[listItems]
);
const icon = useMemo(
() => <EuiIcon size="xl" type={connectorsConfiguration[`${connectorType}`]?.logo ?? ''} />,
[connectorType]
);
return (
<>
{isLoading && <EuiLoadingSpinner data-test-subj="settings-connector-card-loading" />}
{!isLoading && (
<EuiCard
data-test-subj={`settings-connector-card`}
description={description}
display="plain"
icon={icon}
layout="horizontal"
paddingSize="none"
title={title}
titleSize="xs"
/>
)}
</>
);
};
export const ConnectorCard = memo(ConnectorCardDisplay);

View file

@ -0,0 +1,60 @@
/*
* 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, { memo, Suspense, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { CaseSettingsConnector, SettingFieldsProps } from './types';
import { getCaseSettings } from '.';
import { ConnectorTypeFields } from '../../../../../case/common/api/connectors';
interface Props extends Omit<SettingFieldsProps<ConnectorTypeFields['fields']>, 'connector'> {
connector: CaseSettingsConnector | null;
}
const SettingFieldsFormComponent: React.FC<Props> = ({ connector, isEdit, onChange, fields }) => {
const { caseSettingsRegistry } = getCaseSettings();
const onFieldsChange = useCallback(
(newFields) => {
onChange(newFields);
},
[onChange]
);
if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') {
return null;
}
const { caseSettingFieldsComponent: FieldsComponent } = caseSettingsRegistry.get(
connector.actionTypeId
);
return (
<>
{FieldsComponent != null ? (
<Suspense
fallback={
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
</EuiFlexGroup>
}
>
<FieldsComponent
isEdit={isEdit}
fields={fields}
connector={connector}
onChange={onFieldsChange}
/>
</Suspense>
) : null}
</>
);
};
export const SettingFieldsForm = memo(SettingFieldsFormComponent);

View file

@ -0,0 +1,47 @@
/*
* 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 { CaseSettingsRegistry } from './types';
import { createCaseSettingsRegistry } from './settings_registry';
import { getCaseSetting as getJiraCaseSetting } from './jira';
import { getCaseSetting as getResilientCaseSetting } from './resilient';
import { getCaseSetting as getServiceNowCaseSetting } from './servicenow';
import {
JiraFieldsType,
ServiceNowFieldsType,
ResilientFieldsType,
} from '../../../../../case/common/api/connectors';
interface GetCaseSettingReturn {
caseSettingsRegistry: CaseSettingsRegistry;
}
class CaseSettings {
private caseSettingsRegistry: CaseSettingsRegistry;
constructor() {
this.caseSettingsRegistry = createCaseSettingsRegistry();
this.init();
}
private init() {
this.caseSettingsRegistry.register<JiraFieldsType>(getJiraCaseSetting());
this.caseSettingsRegistry.register<ResilientFieldsType>(getResilientCaseSetting());
this.caseSettingsRegistry.register<ServiceNowFieldsType>(getServiceNowCaseSetting());
}
registry(): CaseSettingsRegistry {
return this.caseSettingsRegistry;
}
}
const caseSettings = new CaseSettings();
export const getCaseSettings = (): GetCaseSettingReturn => {
return {
caseSettingsRegistry: caseSettings.registry(),
};
};

View file

@ -0,0 +1,39 @@
/*
* 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 { GetIssueTypesProps, GetFieldsByIssueTypeProps } from '../api';
import { IssueTypes, Fields } from '../types';
const issueTypes = [
{
id: '10006',
name: 'Task',
},
{
id: '10007',
name: 'Bug',
},
];
const fieldsByIssueType = {
summary: { allowedValues: [], defaultValue: {} },
priority: {
allowedValues: [
{
name: 'Medium',
id: '3',
},
],
defaultValue: { name: 'Medium', id: '3' },
},
};
export const getIssueTypes = async (props: GetIssueTypesProps): Promise<{ data: IssueTypes }> =>
Promise.resolve({ data: issueTypes });
export const getFieldsByIssueType = async (
props: GetFieldsByIssueTypeProps
): Promise<{ data: Fields }> => Promise.resolve({ data: fieldsByIssueType });

View file

@ -0,0 +1,159 @@
/*
* 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 { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import { getIssueTypes, getFieldsByIssueType, getIssues, getIssue } from './api';
const issueTypesResponse = {
data: {
projects: [
{
issuetypes: [
{
id: '10006',
name: 'Task',
},
{
id: '10007',
name: 'Bug',
},
],
},
],
},
};
const fieldsResponse = {
data: {
projects: [
{
issuetypes: [
{
id: '10006',
name: 'Task',
fields: {
summary: { fieldId: 'summary' },
priority: {
fieldId: 'priority',
allowedValues: [
{
name: 'Highest',
id: '1',
},
{
name: 'High',
id: '2',
},
{
name: 'Medium',
id: '3',
},
{
name: 'Low',
id: '4',
},
{
name: 'Lowest',
id: '5',
},
],
defaultValue: {
name: 'Medium',
id: '3',
},
},
},
},
],
},
],
},
};
const issueResponse = {
id: '10267',
key: 'RJ-107',
fields: { summary: 'Test title' },
};
const issuesResponse = [issueResponse];
describe('Jira API', () => {
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('getIssueTypes', () => {
test('should call get issue types API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(issueTypesResponse);
const res = await getIssueTypes({ http, signal: abortCtrl.signal, connectorId: 'test' });
expect(res).toEqual(issueTypesResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"issueTypes","subActionParams":{}}}',
signal: abortCtrl.signal,
});
});
});
describe('getFieldsByIssueType', () => {
test('should call get fields API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(fieldsResponse);
const res = await getFieldsByIssueType({
http,
signal: abortCtrl.signal,
connectorId: 'test',
id: '10006',
});
expect(res).toEqual(fieldsResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"fieldsByIssueType","subActionParams":{"id":"10006"}}}',
signal: abortCtrl.signal,
});
});
});
describe('getIssues', () => {
test('should call get fields API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(issuesResponse);
const res = await getIssues({
http,
signal: abortCtrl.signal,
connectorId: 'test',
title: 'test issue',
});
expect(res).toEqual(issuesResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"issues","subActionParams":{"title":"test issue"}}}',
signal: abortCtrl.signal,
});
});
});
describe('getIssue', () => {
test('should call get fields API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(issuesResponse);
const res = await getIssue({
http,
signal: abortCtrl.signal,
connectorId: 'test',
id: 'RJ-107',
});
expect(res).toEqual(issuesResponse);
expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', {
body: '{"params":{"subAction":"issue","subActionParams":{"id":"RJ-107"}}}',
signal: abortCtrl.signal,
});
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 { HttpSetup } from 'kibana/public';
import { ActionTypeExecutorResult } from '../../../../../../case/common/api';
import { IssueTypes, Fields, Issues, Issue } from './types';
export const BASE_ACTION_API_PATH = '/api/actions';
export interface GetIssueTypesProps {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
}
export async function getIssueTypes({ http, signal, connectorId }: GetIssueTypesProps) {
return http.post<ActionTypeExecutorResult<IssueTypes>>(
`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`,
{
body: JSON.stringify({
params: { subAction: 'issueTypes', subActionParams: {} },
}),
signal,
}
);
}
export interface GetFieldsByIssueTypeProps {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
id: string;
}
export async function getFieldsByIssueType({
http,
signal,
connectorId,
id,
}: GetFieldsByIssueTypeProps): Promise<ActionTypeExecutorResult<Fields>> {
return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'fieldsByIssueType', subActionParams: { id } },
}),
signal,
});
}
export interface GetIssuesTypeProps {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
title: string;
}
export async function getIssues({
http,
signal,
connectorId,
title,
}: GetIssuesTypeProps): Promise<ActionTypeExecutorResult<Issues>> {
return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'issues', subActionParams: { title } },
}),
signal,
});
}
export interface GetIssueTypeProps {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
id: string;
}
export async function getIssue({
http,
signal,
connectorId,
id,
}: GetIssueTypeProps): Promise<ActionTypeExecutorResult<Issue>> {
return http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'issue', subActionParams: { id } },
}),
signal,
});
}

View file

@ -0,0 +1,156 @@
/*
* 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 } from 'enzyme';
import { omit } from 'lodash/fp';
import { connector } from '../mock';
import { useGetIssueTypes } from './use_get_issue_types';
import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import Fields from './fields';
jest.mock('../../../../common/lib/kibana');
jest.mock('./use_get_issue_types');
jest.mock('./use_get_fields_by_issue_type');
const useGetIssueTypesMock = useGetIssueTypes as jest.Mock;
const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock;
describe('JiraParamsFields renders', () => {
const useGetIssueTypesResponse = {
isLoading: false,
issueTypes: [
{
id: '10006',
name: 'Task',
},
{
id: '10007',
name: 'Bug',
},
],
};
const useGetFieldsByIssueTypeResponse = {
isLoading: false,
fields: {
summary: { allowedValues: [], defaultValue: {} },
labels: { allowedValues: [], defaultValue: {} },
description: { allowedValues: [], defaultValue: {} },
priority: {
allowedValues: [
{
name: 'Medium',
id: '3',
},
{
name: 'Low',
id: '2',
},
],
defaultValue: { name: 'Medium', id: '3' },
},
},
};
const fields = {
issueType: '10006',
priority: 'High',
parent: null,
};
const onChange = jest.fn();
beforeEach(() => {
useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse);
useGetFieldsByIssueTypeMock.mockReturnValue(useGetFieldsByIssueTypeResponse);
jest.clearAllMocks();
});
test('all params fields are rendered', () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('value')).toStrictEqual(
'10006'
);
expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('value')).toStrictEqual(
'High'
);
});
test('it disabled the fields when loading issue types', () => {
useGetIssueTypesMock.mockReturnValue({ ...useGetIssueTypesResponse, isLoading: true });
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(
wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled')
).toBeTruthy();
expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy();
});
test('it disabled the fields when loading fields', () => {
useGetFieldsByIssueTypeMock.mockReturnValue({
...useGetFieldsByIssueTypeResponse,
isLoading: true,
});
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(
wrapper.find('[data-test-subj="issueTypeSelect"]').first().prop('disabled')
).toBeTruthy();
expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('disabled')).toBeTruthy();
});
test('it hides the priority if not supported', () => {
const response = omit('fields.priority', useGetFieldsByIssueTypeResponse);
useGetFieldsByIssueTypeMock.mockReturnValue(response);
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(wrapper.find('[data-test-subj="prioritySelect"]').first().exists()).toBeFalsy();
});
test('it sets issue type correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
wrapper
.find('select[data-test-subj="issueTypeSelect"]')
.first()
.simulate('change', {
target: { value: '10007' },
});
expect(onChange).toHaveBeenCalledWith({ issueType: '10007', parent: null, priority: null });
});
test('it sets priority correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
wrapper
.find('select[data-test-subj="prioritySelect"]')
.first()
.simulate('change', {
target: { value: '2' },
});
expect(onChange).toHaveBeenCalledWith({ issueType: '10006', parent: null, priority: '2' });
});
test('it resets priority when changing issue type', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
wrapper
.find('select[data-test-subj="issueTypeSelect"]')
.first()
.simulate('change', {
target: { value: '10007' },
});
expect(onChange).toBeCalledWith({ issueType: '10007', parent: null, priority: null });
});
});

View file

@ -0,0 +1,203 @@
/*
* 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, { useCallback, useMemo } from 'react';
import { map } from 'lodash/fp';
import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import * as i18n from './translations';
import { ConnectorTypes, JiraFieldsType } from '../../../../../../case/common/api/connectors';
import { useKibana } from '../../../../common/lib/kibana';
import { SettingFieldsProps } from '../types';
import { useGetIssueTypes } from './use_get_issue_types';
import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import { SearchIssues } from './search_issues';
import { ConnectorCard } from '../card';
const JiraSettingFieldsComponent: React.FunctionComponent<SettingFieldsProps<JiraFieldsType>> = ({
connector,
fields,
isEdit = true,
onChange,
}) => {
const { issueType = null, priority = null, parent = null } = fields ?? {};
const { http, notifications } = useKibana().services;
const handleIssueType = useCallback(
(issueTypeSelectOptions: Array<{ value: string; text: string }>) => {
if (issueType == null && issueTypeSelectOptions.length > 0) {
// if there is no issue type set in the edit view, set it to default
if (isEdit) {
onChange({
issueType: issueTypeSelectOptions[0].value,
parent,
priority,
});
}
}
},
[isEdit, issueType, onChange, parent, priority]
);
const { isLoading: isLoadingIssueTypes, issueTypes } = useGetIssueTypes({
connector,
http,
toastNotifications: notifications.toasts,
handleIssueType,
});
const issueTypesSelectOptions = useMemo(
() =>
issueTypes.map((type) => ({
text: type.name ?? '',
value: type.id ?? '',
})),
[issueTypes]
);
const currentIssueType = useMemo(() => {
if (!issueType && issueTypesSelectOptions.length > 0) {
return issueTypesSelectOptions[0].value;
} else if (
issueTypesSelectOptions.length > 0 &&
!issueTypesSelectOptions.some(({ value }) => value === issueType)
) {
return issueTypesSelectOptions[0].value;
}
return issueType;
}, [issueType, issueTypesSelectOptions]);
const { isLoading: isLoadingFields, fields: fieldsByIssueType } = useGetFieldsByIssueType({
connector,
http,
issueType: currentIssueType,
toastNotifications: notifications.toasts,
});
const hasPriority = useMemo(() => fieldsByIssueType.priority != null, [fieldsByIssueType]);
const hasParent = useMemo(() => fieldsByIssueType.parent != null, [fieldsByIssueType]);
const prioritiesSelectOptions = useMemo(() => {
const priorities = fieldsByIssueType.priority?.allowedValues ?? [];
return map(
(p) => ({
text: p.name,
value: p.name,
}),
priorities
);
}, [fieldsByIssueType]);
const listItems = useMemo(
() => [
...(issueType != null && issueType.length > 0
? [
{
title: i18n.ISSUE_TYPE,
description: issueTypes.find((issue) => issue.id === issueType)?.name ?? '',
},
]
: []),
...(parent != null && parent.length > 0
? [
{
title: i18n.PARENT_ISSUE,
description: parent,
},
]
: []),
...(priority != null && priority.length > 0
? [
{
title: i18n.PRIORITY,
description: priority,
},
]
: []),
],
[issueType, issueTypes, parent, priority]
);
const onFieldChange = useCallback(
(key, value) => {
if (key === 'issueType') {
return onChange({ ...fields, issueType: value, priority: null, parent: null });
}
return onChange({
...fields,
issueType: currentIssueType,
parent,
priority,
[key]: value,
});
},
[currentIssueType, fields, onChange, parent, priority]
);
return isEdit ? (
<span data-test-subj={'connector-settings-jira'}>
<EuiFormRow fullWidth label={i18n.ISSUE_TYPE}>
<EuiSelect
data-test-subj="issueTypeSelect"
disabled={isLoadingIssueTypes || isLoadingFields}
fullWidth
isLoading={isLoadingIssueTypes}
onChange={(e) => onFieldChange('issueType', e.target.value)}
options={issueTypesSelectOptions}
value={currentIssueType ?? ''}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<>
{hasParent && (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.PARENT_ISSUE}>
<SearchIssues
actionConnector={connector}
onChange={(parentIssueKey) => onFieldChange('parent', parentIssueKey)}
selectedValue={parent}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
)}
{hasPriority && (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.PRIORITY}>
<EuiSelect
data-test-subj="prioritySelect"
disabled={isLoadingIssueTypes || isLoadingFields}
fullWidth
hasNoInitialSelection
isLoading={isLoadingFields}
onChange={(e) => onFieldChange('priority', e.target.value)}
options={prioritiesSelectOptions}
value={priority ?? ''}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</>
</span>
) : (
<ConnectorCard
connectorType={ConnectorTypes.jira}
isLoading={isLoadingIssueTypes || isLoadingFields}
listItems={listItems}
title={connector.name}
/>
);
};
// eslint-disable-next-line import/no-default-export
export { JiraSettingFieldsComponent as default };

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { lazy } from 'react';
import { CaseSetting } from '../types';
import { JiraFieldsType } from '../../../../../../case/common/api/connectors';
import * as i18n from './translations';
export * from './types';
export const getCaseSetting = (): CaseSetting<JiraFieldsType> => {
return {
id: '.jira',
caseSettingFieldsComponent: lazy(() => import('./fields')),
};
};
export const fieldLabels = {
issueType: i18n.ISSUE_TYPE,
priority: i18n.PRIORITY,
parent: i18n.PARENT_ISSUE,
};

View file

@ -0,0 +1,94 @@
/*
* 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, useEffect, useCallback, useState, memo } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionConnector } from '../../../containers/types';
import { useGetIssues } from './use_get_issues';
import { useGetSingleIssue } from './use_get_single_issue';
import * as i18n from './translations';
interface Props {
selectedValue: string | null;
actionConnector?: ActionConnector;
onChange: (parentIssueKey: string) => void;
}
const SearchIssuesComponent: React.FC<Props> = ({ selectedValue, actionConnector, onChange }) => {
const [query, setQuery] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>(
[]
);
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const { http, notifications } = useKibana().services;
const { isLoading: isLoadingIssues, issues } = useGetIssues({
http,
toastNotifications: notifications.toasts,
actionConnector,
query,
});
const { isLoading: isLoadingSingleIssue, issue: singleIssue } = useGetSingleIssue({
http,
toastNotifications: notifications.toasts,
actionConnector,
id: selectedValue,
});
useEffect(() => setOptions(issues.map((issue) => ({ label: issue.title, value: issue.key }))), [
issues,
]);
useEffect(() => {
if (isLoadingSingleIssue || singleIssue == null) {
return;
}
const singleIssueAsOptions = [{ label: singleIssue.title, value: singleIssue.key }];
setOptions(singleIssueAsOptions);
setSelectedOptions(singleIssueAsOptions);
}, [singleIssue, isLoadingSingleIssue]);
const onSearchChange = useCallback((searchVal: string) => {
setQuery(searchVal);
}, []);
const onChangeComboBox = useCallback(
(changedOptions) => {
setSelectedOptions(changedOptions);
onChange(changedOptions[0].value);
},
[onChange]
);
const inputPlaceholder = useMemo(
(): string =>
isLoadingIssues || isLoadingSingleIssue
? i18n.SEARCH_ISSUES_LOADING
: i18n.SEARCH_ISSUES_PLACEHOLDER,
[isLoadingIssues, isLoadingSingleIssue]
);
return (
<EuiComboBox
singleSelection
fullWidth
placeholder={inputPlaceholder}
data-test-sub={'search-parent-issues'}
aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL}
options={options}
isLoading={isLoadingIssues || isLoadingSingleIssue}
onSearchChange={onSearchChange}
selectedOptions={selectedOptions}
onChange={onChangeComboBox}
/>
);
};
export const SearchIssues = memo(SearchIssuesComponent);

View file

@ -0,0 +1,76 @@
/*
* 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 ISSUE_TYPES_API_ERROR = i18n.translate(
'xpack.securitySolution.components.settings.jira.unableToGetIssueTypesMessage',
{
defaultMessage: 'Unable to get issue types',
}
);
export const FIELDS_API_ERROR = i18n.translate(
'xpack.securitySolution.components.settings.jira.unableToGetFieldsMessage',
{
defaultMessage: 'Unable to get fields',
}
);
export const ISSUES_API_ERROR = i18n.translate(
'xpack.securitySolution.components.settings.jira.unableToGetIssuesMessage',
{
defaultMessage: 'Unable to get issues',
}
);
export const GET_ISSUE_API_ERROR = (id: string) =>
i18n.translate('xpack.securitySolution.components.settings.jira.unableToGetIssueMessage', {
defaultMessage: 'Unable to get issue with id {id}',
values: { id },
});
export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate(
'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxAriaLabel',
{
defaultMessage: 'Select parent issue',
}
);
export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxPlaceholder',
{
defaultMessage: 'Select parent issue',
}
);
export const SEARCH_ISSUES_LOADING = i18n.translate(
'xpack.securitySolution.components.settings.jira.searchIssuesLoading',
{
defaultMessage: 'Loading...',
}
);
export const PRIORITY = i18n.translate(
'xpack.securitySolution.case.settings.jira.prioritySelectFieldLabel',
{
defaultMessage: 'Priority',
}
);
export const ISSUE_TYPE = i18n.translate(
'xpack.securitySolution.case.settings.jira.issueTypesSelectFieldLabel',
{
defaultMessage: 'Issue type',
}
);
export const PARENT_ISSUE = i18n.translate(
'xpack.securitySolution.case.settings.jira.parentIssueSearchLabel',
{
defaultMessage: 'Parent issue',
}
);

View file

@ -0,0 +1,21 @@
/*
* 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 type IssueTypes = Array<{ id: string; name: string }>;
export interface Fields {
[key: string]: {
allowedValues: Array<{ name: string; id: string }> | [];
defaultValue: { name: string; id: string } | {};
};
}
export interface Issue {
id: string;
key: string;
title: string;
}
export type Issues = Issue[];

View file

@ -0,0 +1,104 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { connector } from '../mock';
import { useGetFieldsByIssueType, UseGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import * as api from './api';
jest.mock('../../../../common/lib/kibana');
jest.mock('./api');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('useGetFieldsByIssueType', () => {
const { http, notifications } = useKibanaMock().services;
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetFieldsByIssueType>(() =>
useGetFieldsByIssueType({ http, toastNotifications: notifications.toasts, issueType: null })
);
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: true, fields: {} });
});
});
test('does not fetch when issueType is not provided', async () => {
const spyOnGetFieldsByIssueType = jest.spyOn(api, 'getFieldsByIssueType');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetFieldsByIssueType>(() =>
useGetFieldsByIssueType({
http,
toastNotifications: notifications.toasts,
connector,
issueType: null,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnGetFieldsByIssueType).not.toHaveBeenCalled();
expect(result.current).toEqual({ isLoading: false, fields: {} });
});
});
test('fetch fields', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetFieldsByIssueType>(() =>
useGetFieldsByIssueType({
http,
toastNotifications: notifications.toasts,
connector,
issueType: 'Task',
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
fields: {
summary: { allowedValues: [], defaultValue: {} },
priority: {
allowedValues: [
{
name: 'Medium',
id: '3',
},
],
defaultValue: { name: 'Medium', id: '3' },
},
},
});
});
});
test('unhappy path', async () => {
const spyOnGetCaseConfigure = jest.spyOn(api, 'getFieldsByIssueType');
spyOnGetCaseConfigure.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetFieldsByIssueType>(() =>
useGetFieldsByIssueType({
http,
toastNotifications: notifications.toasts,
connector,
issueType: null,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: false, fields: {} });
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../containers/types';
import { getFieldsByIssueType } from './api';
import { Fields } from './types';
import * as i18n from './translations';
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
issueType: string | null;
connector?: ActionConnector;
}
export interface UseGetFieldsByIssueType {
fields: Fields;
isLoading: boolean;
}
export const useGetFieldsByIssueType = ({
http,
toastNotifications,
connector,
issueType,
}: Props): UseGetFieldsByIssueType => {
const [isLoading, setIsLoading] = useState(true);
const [fields, setFields] = useState<Fields>({});
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!connector || !issueType) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getFieldsByIssueType({
http,
signal: abortCtrl.current.signal,
connectorId: connector.id,
id: issueType,
});
if (!didCancel) {
setIsLoading(false);
setFields(res.data ?? {});
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.FIELDS_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
setIsLoading(false);
toastNotifications.addDanger({
title: i18n.FIELDS_API_ERROR,
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, connector, issueType, toastNotifications]);
return {
isLoading,
fields,
};
};

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { connector } from '../mock';
import { useGetIssueTypes, UseGetIssueTypes } from './use_get_issue_types';
import * as api from './api';
jest.mock('../../../../common/lib/kibana');
jest.mock('./api');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('useGetIssueTypes', () => {
const { http, notifications } = useKibanaMock().services;
const handleIssueType = jest.fn();
beforeEach(() => jest.clearAllMocks());
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetIssueTypes>(() =>
useGetIssueTypes({ http, toastNotifications: notifications.toasts, handleIssueType })
);
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: true, issueTypes: [] });
});
});
test('fetch issue types', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetIssueTypes>(() =>
useGetIssueTypes({
http,
toastNotifications: notifications.toasts,
connector,
handleIssueType,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
issueTypes: [
{
id: '10006',
name: 'Task',
},
{
id: '10007',
name: 'Bug',
},
],
});
});
});
test('handleIssueType is called', async () => {
await act(async () => {
const { waitForNextUpdate } = renderHook<string, UseGetIssueTypes>(() =>
useGetIssueTypes({
http,
toastNotifications: notifications.toasts,
connector,
handleIssueType,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(handleIssueType).toHaveBeenCalledWith([
{ text: 'Task', value: '10006' },
{ text: 'Bug', value: '10007' },
]);
});
});
test('unhappy path', async () => {
const spyOnGetCaseConfigure = jest.spyOn(api, 'getIssueTypes');
spyOnGetCaseConfigure.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetIssueTypes>(() =>
useGetIssueTypes({
http,
toastNotifications: notifications.toasts,
connector,
handleIssueType,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: false, issueTypes: [] });
});
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../containers/types';
import { getIssueTypes } from './api';
import { IssueTypes } from './types';
import * as i18n from './translations';
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
connector?: ActionConnector;
handleIssueType: (options: Array<{ value: string; text: string }>) => void;
}
export interface UseGetIssueTypes {
issueTypes: IssueTypes;
isLoading: boolean;
}
export const useGetIssueTypes = ({
http,
connector,
toastNotifications,
handleIssueType,
}: Props): UseGetIssueTypes => {
const [isLoading, setIsLoading] = useState(true);
const [issueTypes, setIssueTypes] = useState<IssueTypes>([]);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!connector) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIssueTypes({
http,
signal: abortCtrl.current.signal,
connectorId: connector.id,
});
if (!didCancel) {
setIsLoading(false);
const asOptions = (res.data ?? []).map((type) => ({
text: type.name ?? '',
value: type.id ?? '',
}));
setIssueTypes(res.data ?? []);
handleIssueType(asOptions);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.ISSUE_TYPES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
setIsLoading(false);
toastNotifications.addDanger({
title: i18n.ISSUE_TYPES_API_ERROR,
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, connector, toastNotifications, handleIssueType]);
return {
issueTypes,
isLoading,
};
};

View file

@ -0,0 +1,94 @@
/*
* 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 { isEmpty, debounce } from 'lodash/fp';
import { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../containers/types';
import { getIssues } from './api';
import { Issues } from './types';
import * as i18n from './translations';
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
query: string | null;
}
export interface UseGetIssues {
issues: Issues;
isLoading: boolean;
}
export const useGetIssues = ({
http,
actionConnector,
toastNotifications,
query,
}: Props): UseGetIssues => {
const [isLoading, setIsLoading] = useState(false);
const [issues, setIssues] = useState<Issues>([]);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = debounce(500, async () => {
if (!actionConnector || isEmpty(query)) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIssues({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
title: query ?? '',
});
if (!didCancel) {
setIsLoading(false);
setIssues(res.data ?? []);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.ISSUES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
setIsLoading(false);
toastNotifications.addDanger({
title: i18n.ISSUES_API_ERROR,
text: error.message,
});
}
}
});
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, actionConnector, toastNotifications, query]);
return {
issues,
isLoading,
};
};

View file

@ -0,0 +1,92 @@
/*
* 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 { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../containers/types';
import { getIssue } from './api';
import { Issue } from './types';
import * as i18n from './translations';
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
id: string | null;
actionConnector?: ActionConnector;
}
export interface UseGetSingleIssue {
issue: Issue | null;
isLoading: boolean;
}
export const useGetSingleIssue = ({
http,
toastNotifications,
actionConnector,
id,
}: Props): UseGetSingleIssue => {
const [isLoading, setIsLoading] = useState(false);
const [issue, setIssue] = useState<Issue | null>(null);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!actionConnector || !id) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIssue({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
id,
});
if (!didCancel) {
setIsLoading(false);
setIssue(res.data ?? null);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.GET_ISSUE_API_ERROR(id),
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
setIsLoading(false);
toastNotifications.addDanger({
title: i18n.GET_ISSUE_API_ERROR(id),
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, actionConnector, id, toastNotifications]);
return {
isLoading,
issue,
};
};

View file

@ -0,0 +1,13 @@
/*
* 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 connector = {
id: '123',
name: 'My connector',
actionTypeId: '.jira',
config: {},
isPreconfigured: false,
};

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Props } from '../api';
import { ResilientIncidentTypes, ResilientSeverity } from '../types';
const severity = [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
];
const incidentTypes = [
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
];
export const getIncidentTypes = async (props: Props): Promise<{ data: ResilientIncidentTypes }> =>
Promise.resolve({ data: incidentTypes });
export const getSeverity = async (props: Props): Promise<{ data: ResilientSeverity }> =>
Promise.resolve({ data: severity });

View file

@ -0,0 +1,41 @@
/*
* 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 { HttpSetup } from 'kibana/public';
import { ActionTypeExecutorResult } from '../../../../../../case/common/api';
import { ResilientIncidentTypes, ResilientSeverity } from './types';
export const BASE_ACTION_API_PATH = '/api/actions';
export interface Props {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
}
export async function getIncidentTypes({ http, signal, connectorId }: Props) {
return http.post<ActionTypeExecutorResult<ResilientIncidentTypes>>(
`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`,
{
body: JSON.stringify({
params: { subAction: 'incidentTypes', subActionParams: {} },
}),
signal,
}
);
}
export async function getSeverity({ http, signal, connectorId }: Props) {
return http.post<ActionTypeExecutorResult<ResilientSeverity>>(
`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`,
{
body: JSON.stringify({
params: { subAction: 'severity', subActionParams: {} },
}),
signal,
}
);
}

View file

@ -0,0 +1,133 @@
/*
* 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 } from 'enzyme';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { waitFor } from '@testing-library/react';
import { connector } from '../mock';
import { useGetIncidentTypes } from './use_get_incident_types';
import { useGetSeverity } from './use_get_severity';
import Fields from './fields';
jest.mock('../../../../common/lib/kibana');
jest.mock('./use_get_incident_types');
jest.mock('./use_get_severity');
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
describe('ResilientParamsFields renders', () => {
const useGetIncidentTypesResponse = {
isLoading: false,
incidentTypes: [
{
id: 19,
name: 'Malware',
},
{
id: 21,
name: 'Denial of Service',
},
],
};
const useGetSeverityResponse = {
isLoading: false,
severity: [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
],
};
const fields = {
severityCode: '6',
incidentTypes: ['19'],
};
const onChange = jest.fn();
beforeEach(() => {
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
jest.clearAllMocks();
});
test('all params fields are rendered', () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('options')).toEqual(
[
{ label: 'Malware', value: '19' },
{ label: 'Denial of Service', value: '21' },
]
);
expect(
wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('selectedOptions')
).toEqual([{ label: 'Malware', value: '19' }]);
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual(
'6'
);
});
test('it disabled the fields when loading incident types', () => {
useGetIncidentTypesMock.mockReturnValue({ ...useGetIncidentTypesResponse, isLoading: true });
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(
wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('isDisabled')
).toBeTruthy();
});
test('it disabled the fields when loading severity', () => {
useGetSeverityMock.mockReturnValue({
...useGetSeverityResponse,
isLoading: true,
});
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('disabled')).toBeTruthy();
});
test('it sets issue type correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
await waitFor(() => {
((wrapper.find(EuiComboBox).props() as unknown) as {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange([{ value: '19', label: 'Denial of Service' }]);
});
expect(onChange).toHaveBeenCalledWith({ incidentTypes: ['19'], severityCode: '6' });
});
test('it sets severity correctly', async () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);
wrapper
.find('select[data-test-subj="severitySelect"]')
.first()
.simulate('change', {
target: { value: '4' },
});
expect(onChange).toHaveBeenCalledWith({ incidentTypes: ['19'], severityCode: '4' });
});
});

View file

@ -0,0 +1,186 @@
/*
* 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, useCallback, useEffect } from 'react';
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFormRow,
EuiSelect,
EuiSelectOption,
EuiSpacer,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { SettingFieldsProps } from '../types';
import { useGetIncidentTypes } from './use_get_incident_types';
import { useGetSeverity } from './use_get_severity';
import * as i18n from './translations';
import { ConnectorTypes, ResilientFieldsType } from '../../../../../../case/common/api/connectors';
import { ConnectorCard } from '../card';
const ResilientSettingFieldsComponent: React.FunctionComponent<SettingFieldsProps<
ResilientFieldsType
>> = ({ isEdit = true, fields, connector, onChange }) => {
const { incidentTypes = null, severityCode = null } = fields ?? {};
const { http, notifications } = useKibana().services;
const {
isLoading: isLoadingIncidentTypes,
incidentTypes: allIncidentTypes,
} = useGetIncidentTypes({
http,
toastNotifications: notifications.toasts,
connector,
});
const { isLoading: isLoadingSeverity, severity } = useGetSeverity({
http,
toastNotifications: notifications.toasts,
connector,
});
const severitySelectOptions: EuiSelectOption[] = useMemo(
() =>
severity.map((s) => ({
value: s.id.toString(),
text: s.name,
})),
[severity]
);
const incidentTypesComboBoxOptions: Array<EuiComboBoxOptionOption<string>> = useMemo(
() =>
allIncidentTypes
? allIncidentTypes.map((type: { id: number; name: string }) => ({
label: type.name,
value: type.id.toString(),
}))
: [],
[allIncidentTypes]
);
const listItems = useMemo(
() => [
...(incidentTypes != null && incidentTypes.length > 0
? [
{
title: i18n.INCIDENT_TYPES_LABEL,
description: allIncidentTypes
.filter((type) => incidentTypes.includes(type.id.toString()))
.map((type) => type.name)
.join(', '),
},
]
: []),
...(severityCode != null && severityCode.length > 0
? [
{
title: i18n.SEVERITY_LABEL,
description:
severity.find((severityObj) => severityObj.id.toString() === severityCode)?.name ??
'',
},
]
: []),
],
[incidentTypes, severityCode, allIncidentTypes, severity]
);
const onFieldChange = useCallback(
(key, value) => {
onChange({
...fields,
incidentTypes,
severityCode,
[key]: value,
});
},
[incidentTypes, severityCode, onChange, fields]
);
const selectedIncidentTypesComboBoxOptionsMemo = useMemo(() => {
const allIncidentTypesAsObject = allIncidentTypes.reduce(
(acc, type) => ({ ...acc, [type.id.toString()]: type.name }),
{} as Record<string, string>
);
return incidentTypes
? incidentTypes
.map((type) => ({
label: allIncidentTypesAsObject[type.toString()],
value: type.toString(),
}))
.filter((type) => type.label != null)
: [];
}, [allIncidentTypes, incidentTypes]);
const onIncidentChange = useCallback(
(selectedOptions: Array<{ label: string; value?: string }>) => {
onFieldChange(
'incidentTypes',
selectedOptions.map((selectedOption) => selectedOption.value ?? selectedOption.label)
);
},
[onFieldChange]
);
const onIncidentBlur = useCallback(() => {
if (!incidentTypes) {
onFieldChange('incidentTypes', []);
}
}, [incidentTypes, onFieldChange]);
// We need to set them up at initialization
useEffect(() => {
onChange({ incidentTypes, severityCode });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return isEdit ? (
<span data-test-subj={'connector-settings-resilient'}>
<EuiFormRow fullWidth label={i18n.INCIDENT_TYPES_LABEL}>
<EuiComboBox
data-test-subj="incidentTypeComboBox"
fullWidth
isClearable={true}
isDisabled={isLoadingIncidentTypes}
isLoading={isLoadingIncidentTypes}
onBlur={onIncidentBlur}
onChange={onIncidentChange}
options={incidentTypesComboBoxOptions}
placeholder={i18n.INCIDENT_TYPES_PLACEHOLDER}
selectedOptions={selectedIncidentTypesComboBoxOptionsMemo}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.SEVERITY_LABEL}>
<EuiSelect
data-test-subj="severitySelect"
disabled={isLoadingSeverity}
fullWidth
hasNoInitialSelection
isLoading={isLoadingSeverity}
onChange={(e) => onFieldChange('severityCode', e.target.value)}
options={severitySelectOptions}
value={severityCode ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
</span>
) : (
<ConnectorCard
connectorType={ConnectorTypes.resilient}
isLoading={isLoadingIncidentTypes || isLoadingSeverity}
listItems={listItems}
title={connector.name}
/>
);
};
// eslint-disable-next-line import/no-default-export
export { ResilientSettingFieldsComponent as default };

View file

@ -0,0 +1,25 @@
/*
* 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 { lazy } from 'react';
import { CaseSetting } from '../types';
import { ResilientFieldsType } from '../../../../../../case/common/api/connectors';
import * as i18n from './translations';
export * from './types';
export const getCaseSetting = (): CaseSetting<ResilientFieldsType> => {
return {
id: '.resilient',
caseSettingFieldsComponent: lazy(() => import('./fields')),
};
};
export const fieldLabels = {
incidentTypes: i18n.INCIDENT_TYPES_LABEL,
severityCode: i18n.SEVERITY_LABEL,
};

View file

@ -0,0 +1,42 @@
/*
* 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 INCIDENT_TYPES_API_ERROR = i18n.translate(
'xpack.securitySolution.case.settings.resilient.unableToGetIncidentTypesMessage',
{
defaultMessage: 'Unable to get incident types',
}
);
export const SEVERITY_API_ERROR = i18n.translate(
'xpack.securitySolution.case.settings.resilient.unableToGetSeverityMessage',
{
defaultMessage: 'Unable to get severity',
}
);
export const INCIDENT_TYPES_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.case.settings.resilient.incidentTypesPlaceholder',
{
defaultMessage: 'Choose types',
}
);
export const INCIDENT_TYPES_LABEL = i18n.translate(
'xpack.securitySolution.case.settings.resilient.incidentTypesLabel',
{
defaultMessage: 'Incident Types',
}
);
export const SEVERITY_LABEL = i18n.translate(
'xpack.securitySolution.case.settings.resilient.severityLabel',
{
defaultMessage: 'Severity',
}
);

View file

@ -0,0 +1,8 @@
/*
* 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 type ResilientIncidentTypes = Array<{ id: number; name: string }>;
export type ResilientSeverity = ResilientIncidentTypes;

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 { renderHook, act } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { connector } from '../mock';
import { useGetIncidentTypes, UseGetIncidentTypes } from './use_get_incident_types';
import * as api from './api';
jest.mock('../../../../common/lib/kibana');
jest.mock('./api');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('useGetIncidentTypes', () => {
const { http, notifications } = useKibanaMock().services;
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetIncidentTypes>(() =>
useGetIncidentTypes({ http, toastNotifications: notifications.toasts })
);
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: true, incidentTypes: [] });
});
});
test('fetch incident types', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetIncidentTypes>(() =>
useGetIncidentTypes({
http,
toastNotifications: notifications.toasts,
connector,
})
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
incidentTypes: [
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
],
});
});
});
test('unhappy path', async () => {
const spyOnGetCaseConfigure = jest.spyOn(api, 'getIncidentTypes');
spyOnGetCaseConfigure.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetIncidentTypes>(() =>
useGetIncidentTypes({ http, toastNotifications: notifications.toasts, connector })
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: false, incidentTypes: [] });
});
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../containers/types';
import { getIncidentTypes } from './api';
import * as i18n from './translations';
type IncidentTypes = Array<{ id: number; name: string }>;
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
connector?: ActionConnector;
}
export interface UseGetIncidentTypes {
incidentTypes: IncidentTypes;
isLoading: boolean;
}
export const useGetIncidentTypes = ({
http,
toastNotifications,
connector,
}: Props): UseGetIncidentTypes => {
const [isLoading, setIsLoading] = useState(true);
const [incidentTypes, setIncidentTypes] = useState<IncidentTypes>([]);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!connector) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIncidentTypes({
http,
signal: abortCtrl.current.signal,
connectorId: connector.id,
});
if (!didCancel) {
setIsLoading(false);
setIncidentTypes(res.data ?? []);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.INCIDENT_TYPES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
setIsLoading(false);
toastNotifications.addDanger({
title: i18n.INCIDENT_TYPES_API_ERROR,
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, connector, toastNotifications]);
return {
incidentTypes,
isLoading,
};
};

View file

@ -0,0 +1,76 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { connector } from '../mock';
import { useGetSeverity, UseGetSeverity } from './use_get_severity';
import * as api from './api';
jest.mock('../../../../common/lib/kibana');
jest.mock('./api');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('useGetSeverity', () => {
const { http, notifications } = useKibanaMock().services;
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetSeverity>(() =>
useGetSeverity({ http, toastNotifications: notifications.toasts })
);
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: true, severity: [] });
});
});
test('fetch severity', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetSeverity>(() =>
useGetSeverity({ http, toastNotifications: notifications.toasts, connector })
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
severity: [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
],
});
});
});
test('unhappy path', async () => {
const spyOnGetCaseConfigure = jest.spyOn(api, 'getSeverity');
spyOnGetCaseConfigure.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetSeverity>(() =>
useGetSeverity({ http, toastNotifications: notifications.toasts, connector })
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({ isLoading: false, severity: [] });
});
});
});

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