[Security Solution] Webhook - Case Management Connector (#131762)

This commit is contained in:
Steph Milovic 2022-07-26 10:10:05 -06:00 committed by GitHub
parent 967ce6d4de
commit 4f3e55413c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 6380 additions and 101 deletions

View file

@ -0,0 +1,214 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger } from '@kbn/core/server';
import { externalServiceMock, apiParams } from './mock';
import { ExternalService } from './types';
import { api } from './api';
let mockedLogger: jest.Mocked<Logger>;
describe('api', () => {
let externalService: jest.Mocked<ExternalService>;
beforeEach(() => {
externalService = externalServiceMock.create();
});
describe('create incident - cases', () => {
test('it creates an incident', async () => {
const params = { ...apiParams, externalId: null };
const res = await api.pushToService({
externalService,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: 'incident-1',
title: 'CK-1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
comments: [
{
commentId: 'case-comment-1',
pushedDate: '2020-04-27T10:59:46.202Z',
},
{
commentId: 'case-comment-2',
pushedDate: '2020-04-27T10:59:46.202Z',
},
],
});
});
test('it creates an incident without comments', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
const res = await api.pushToService({
externalService,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: 'incident-1',
title: 'CK-1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
});
});
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
tags: ['kibana', 'elastic'],
description: 'Incident description',
title: 'Incident title',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
});
test('it calls createComment correctly', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
});
describe('update incident', () => {
test('it updates an incident', async () => {
const res = await api.pushToService({
externalService,
params: apiParams,
logger: mockedLogger,
});
expect(res).toEqual({
id: 'incident-1',
title: 'CK-1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
comments: [
{
commentId: 'case-comment-1',
pushedDate: '2020-04-27T10:59:46.202Z',
},
{
commentId: 'case-comment-2',
pushedDate: '2020-04-27T10:59:46.202Z',
},
],
});
});
test('it updates an incident without comments', async () => {
const params = { ...apiParams, comments: [] };
const res = await api.pushToService({
externalService,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: 'incident-1',
title: 'CK-1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
});
});
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
tags: ['kibana', 'elastic'],
description: 'Incident description',
title: 'Incident title',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
});
test('it calls updateIncident correctly without mapping', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
description: 'Incident description',
title: 'Incident title',
tags: ['kibana', 'elastic'],
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
});
test('it calls createComment correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
test('it calls createComment correctly without mapping', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
incidentId: 'incident-1',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
});
});
});

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ExternalServiceApi,
Incident,
PushToServiceApiHandlerArgs,
PushToServiceResponse,
} from './types';
const pushToServiceHandler = async ({
externalService,
params,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const {
incident: { externalId, ...rest },
comments,
} = params;
const incident: Incident = rest;
let res: PushToServiceResponse;
if (externalId != null) {
res = await externalService.updateIncident({
incidentId: externalId,
incident,
});
} else {
res = await externalService.createIncident({
incident,
});
}
if (comments && Array.isArray(comments) && comments.length > 0) {
res.comments = [];
for (const currentComment of comments) {
if (!currentComment.comment) {
continue;
}
await externalService.createComment({
incidentId: res.id,
comment: currentComment,
});
res.comments = [
...(res.comments ?? []),
{
commentId: currentComment.commentId,
pushedDate: res.pushedDate,
},
];
}
}
return res;
};
export const api: ExternalServiceApi = {
pushToService: pushToServiceHandler,
};

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { curry } from 'lodash';
import { schema } from '@kbn/config-schema';
import { Logger } from '@kbn/core/server';
import { CasesConnectorFeatureId } from '../../../common';
import {
CasesWebhookActionParamsType,
CasesWebhookExecutorResultData,
CasesWebhookPublicConfigurationType,
CasesWebhookSecretConfigurationType,
ExecutorParams,
ExecutorSubActionPushParams,
} from './types';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { createExternalService } from './service';
import {
ExecutorParamsSchema,
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
} from './schema';
import { api } from './api';
import { validate } from './validators';
import * as i18n from './translations';
const supportedSubActions: string[] = ['pushToService'];
export type ActionParamsType = CasesWebhookActionParamsType;
export const ActionTypeId = '.cases-webhook';
// action type definition
export function getActionType({
logger,
configurationUtilities,
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}): ActionType<
CasesWebhookPublicConfigurationType,
CasesWebhookSecretConfigurationType,
ExecutorParams,
CasesWebhookExecutorResultData
> {
return {
id: ActionTypeId,
minimumLicenseRequired: 'gold',
name: i18n.NAME,
validate: {
config: schema.object(ExternalIncidentServiceConfiguration, {
validate: curry(validate.config)(configurationUtilities),
}),
secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
validate: curry(validate.secrets),
}),
params: ExecutorParamsSchema,
connector: validate.connector,
},
executor: curry(executor)({ logger, configurationUtilities }),
supportedFeatureIds: [CasesConnectorFeatureId],
};
}
// action executor
export async function executor(
{
logger,
configurationUtilities,
}: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities },
execOptions: ActionTypeExecutorOptions<
CasesWebhookPublicConfigurationType,
CasesWebhookSecretConfigurationType,
CasesWebhookActionParamsType
>
): Promise<ActionTypeExecutorResult<CasesWebhookExecutorResultData>> {
const actionId = execOptions.actionId;
const { subAction, subActionParams } = execOptions.params;
let data: CasesWebhookExecutorResultData | undefined;
const externalService = createExternalService(
actionId,
{
config: execOptions.config,
secrets: execOptions.secrets,
},
logger,
configurationUtilities
);
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
data = await api.pushToService({
externalService,
params: pushToServiceParams,
logger,
});
logger.debug(`response push to service for case id: ${data.id}`);
}
return { status: 'ok', data, actionId };
}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ExternalService, ExecutorSubActionPushParams, PushToServiceApiParams } from './types';
const createMock = (): jest.Mocked<ExternalService> => {
const service = {
getIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-1',
key: 'CK-1',
title: 'title from jira',
description: 'description from jira',
created: '2020-04-27T10:59:46.202Z',
updated: '2020-04-27T10:59:46.202Z',
})
),
createIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-1',
title: 'CK-1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
})
),
updateIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-1',
title: 'CK-1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
})
),
createComment: jest.fn(),
};
service.createComment.mockImplementationOnce(() =>
Promise.resolve({
commentId: 'case-comment-1',
pushedDate: '2020-04-27T10:59:46.202Z',
externalCommentId: '1',
})
);
service.createComment.mockImplementationOnce(() =>
Promise.resolve({
commentId: 'case-comment-2',
pushedDate: '2020-04-27T10:59:46.202Z',
externalCommentId: '2',
})
);
return service;
};
export const externalServiceMock = {
create: createMock,
};
const executorParams: ExecutorSubActionPushParams = {
incident: {
externalId: 'incident-3',
title: 'Incident title',
description: 'Incident description',
tags: ['kibana', 'elastic'],
},
comments: [
{
commentId: 'case-comment-1',
comment: 'A comment',
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
},
],
};
export const apiParams: PushToServiceApiParams = executorParams;

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { CasesWebhookMethods } from './types';
import { nullableType } from '../lib/nullable';
const HeadersSchema = schema.recordOf(schema.string(), schema.string());
export const ExternalIncidentServiceConfiguration = {
createIncidentUrl: schema.string(),
createIncidentMethod: schema.oneOf(
[schema.literal(CasesWebhookMethods.POST), schema.literal(CasesWebhookMethods.PUT)],
{
defaultValue: CasesWebhookMethods.POST,
}
),
createIncidentJson: schema.string(), // stringified object
createIncidentResponseKey: schema.string(),
getIncidentUrl: schema.string(),
getIncidentResponseCreatedDateKey: schema.string(),
getIncidentResponseExternalTitleKey: schema.string(),
getIncidentResponseUpdatedDateKey: schema.string(),
incidentViewUrl: schema.string(),
updateIncidentUrl: schema.string(),
updateIncidentMethod: schema.oneOf(
[
schema.literal(CasesWebhookMethods.POST),
schema.literal(CasesWebhookMethods.PATCH),
schema.literal(CasesWebhookMethods.PUT),
],
{
defaultValue: CasesWebhookMethods.PUT,
}
),
updateIncidentJson: schema.string(),
createCommentUrl: schema.nullable(schema.string()),
createCommentMethod: schema.nullable(
schema.oneOf(
[
schema.literal(CasesWebhookMethods.POST),
schema.literal(CasesWebhookMethods.PUT),
schema.literal(CasesWebhookMethods.PATCH),
],
{
defaultValue: CasesWebhookMethods.PUT,
}
)
),
createCommentJson: schema.nullable(schema.string()),
headers: nullableType(HeadersSchema),
hasAuth: schema.boolean({ defaultValue: true }),
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
ExternalIncidentServiceConfiguration
);
export const ExternalIncidentServiceSecretConfiguration = {
user: schema.nullable(schema.string()),
password: schema.nullable(schema.string()),
};
export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
ExternalIncidentServiceSecretConfiguration
);
export const ExecutorSubActionPushParamsSchema = schema.object({
incident: schema.object({
title: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
tags: schema.nullable(schema.arrayOf(schema.string())),
}),
comments: schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
),
});
export const ExecutorParamsSchema = schema.oneOf([
schema.object({
subAction: schema.literal('pushToService'),
subActionParams: ExecutorSubActionPushParamsSchema,
}),
]);

View file

@ -0,0 +1,721 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import axios, { AxiosError, AxiosResponse } from 'axios';
import { createExternalService } from './service';
import { request, createAxiosResponse } from '../lib/axios_utils';
import { CasesWebhookMethods, CasesWebhookPublicConfigurationType, ExternalService } from './types';
import { Logger } from '@kbn/core/server';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('../lib/axios_utils', () => {
const originalUtils = jest.requireActual('../lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = request as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
const config: CasesWebhookPublicConfigurationType = {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: CasesWebhookMethods.POST,
createCommentUrl:
'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"title":{{{case.title}}},"description":{{{case.description}}},"tags":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: CasesWebhookMethods.POST,
createIncidentResponseKey: 'id',
createIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: { ['content-type']: 'application/json' },
incidentViewUrl: 'https://siem-kibana.atlassian.net/browse/{{{external.system.title}}}',
getIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"title":{{{case.title}}},"description":{{{case.description}}},"tags":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: CasesWebhookMethods.PUT,
updateIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
};
const secrets = {
user: 'user',
password: 'pass',
};
const actionId = '1234';
describe('Cases webhook service', () => {
let service: ExternalService;
beforeAll(() => {
service = createExternalService(
actionId,
{
config,
secrets,
},
logger,
configurationUtilities
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('createExternalService', () => {
const requiredUrls = [
'createIncidentUrl',
'incidentViewUrl',
'getIncidentUrl',
'updateIncidentUrl',
];
test.each(requiredUrls)('throws without url %p', (url) => {
expect(() =>
createExternalService(
actionId,
{
config: { ...config, [url]: '' },
secrets,
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws if hasAuth and no user/pass', () => {
expect(() =>
createExternalService(
actionId,
{
config,
secrets: { user: '', password: '' },
},
logger,
configurationUtilities
)
).toThrow();
});
test('does not throw if hasAuth=false and no user/pass', () => {
expect(() =>
createExternalService(
actionId,
{
config: { ...config, hasAuth: false },
secrets: { user: '', password: '' },
},
logger,
configurationUtilities
)
).not.toThrow();
});
});
describe('getIncident', () => {
const axiosRes = {
data: {
id: '1',
key: 'CK-1',
fields: {
title: 'title',
description: 'description',
created: '2021-10-20T19:41:02.754+0300',
updated: '2021-10-20T19:41:02.754+0300',
},
},
};
test('it returns the incident correctly', async () => {
requestMock.mockImplementation(() => createAxiosResponse(axiosRes));
const res = await service.getIncident('1');
expect(res).toEqual({
id: '1',
title: 'CK-1',
createdAt: '2021-10-20T19:41:02.754+0300',
updatedAt: '2021-10-20T19:41:02.754+0300',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => createAxiosResponse(axiosRes));
await service.getIncident('1');
expect(requestMock).toHaveBeenCalledWith({
axios,
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1',
logger,
configurationUtilities,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
const error: AxiosError = new Error('An error has occurred') as AxiosError;
error.response = { statusText: 'Required field' } as AxiosResponse;
throw error;
});
await expect(service.getIncident('1')).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to get case with id 1. Error: An error has occurred. Reason: Required field'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ ...axiosRes, headers: { ['content-type']: 'text/html' } })
);
await expect(service.getIncident('1')).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to get case with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json'
);
});
test('it should throw if the required attributes are not there', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { fields: { notRequired: 'test' } } })
);
await expect(service.getIncident('1')).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to get case with id 1. Error: Response is missing the expected fields: fields.created, key, fields.updated'
);
});
});
describe('createIncident', () => {
const incident = {
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
test('it creates the incident correctly', async () => {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: { id: '1', key: 'CK-1', fields: { title: 'title', description: 'description' } },
})
);
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
fields: { created: '2020-04-27T10:59:46.202Z', updated: '2020-04-27T10:59:46.202Z' },
},
})
);
const res = await service.createIncident(incident);
expect(requestMock.mock.calls[0][0].data).toEqual(
`{"fields":{"title":"title","description":"desc","tags":["hello","world"],"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}`
);
expect(res).toEqual({
title: 'CK-1',
id: '1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
fields: { created: '2020-04-27T10:59:46.202Z' },
},
})
);
requestMock.mockImplementationOnce(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
fields: { created: '2020-04-27T10:59:46.202Z', updated: '2020-04-27T10:59:46.202Z' },
},
})
);
await service.createIncident(incident);
expect(requestMock.mock.calls[0][0]).toEqual({
axios,
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
logger,
method: CasesWebhookMethods.POST,
configurationUtilities,
data: `{"fields":{"title":"title","description":"desc","tags":["hello","world"],"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}`,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
const error: AxiosError = new Error('An error has occurred') as AxiosError;
error.response = { statusText: 'Required field' } as AxiosResponse;
throw error;
});
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create case. Error: An error has occurred. Reason: Required field'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create case. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
test('it should throw if the required attributes are not there', async () => {
requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } }));
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create case. Error: Response is missing the expected field: id.'
);
});
});
describe('updateIncident', () => {
const incident = {
incidentId: '1',
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
},
};
test('it updates the incident correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
fields: { created: '2020-04-27T10:59:46.202Z', updated: '2020-04-27T10:59:46.202Z' },
},
})
);
const res = await service.updateIncident(incident);
expect(res).toEqual({
title: 'CK-1',
id: '1',
pushedDate: '2020-04-27T10:59:46.202Z',
url: 'https://siem-kibana.atlassian.net/browse/CK-1',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
fields: { created: '2020-04-27T10:59:46.202Z', updated: '2020-04-27T10:59:46.202Z' },
},
})
);
await service.updateIncident(incident);
expect(requestMock.mock.calls[0][0]).toEqual({
axios,
logger,
method: CasesWebhookMethods.PUT,
configurationUtilities,
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1',
data: JSON.stringify({
fields: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
project: { key: 'ROC' },
issuetype: { id: '10024' },
},
}),
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
const error: AxiosError = new Error('An error has occurred') as AxiosError;
error.response = { statusText: 'Required field' } as AxiosResponse;
throw error;
});
await expect(service.updateIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to update case with id 1. Error: An error has occurred. Reason: Required field'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.updateIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to update case with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
});
describe('createComment', () => {
const commentReq = {
incidentId: '1',
comment: {
comment: 'comment',
commentId: 'comment-1',
},
};
test('it creates the comment correctly', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
created: '2020-04-27T10:59:46.202Z',
},
})
);
await service.createComment(commentReq);
expect(requestMock.mock.calls[0][0].data).toEqual('{"body":"comment"}');
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '1',
key: 'CK-1',
created: '2020-04-27T10:59:46.202Z',
},
})
);
await service.createComment(commentReq);
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
method: CasesWebhookMethods.POST,
configurationUtilities,
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment',
data: `{"body":"comment"}`,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
const error: AxiosError = new Error('An error has occurred') as AxiosError;
error.response = { statusText: 'Required field' } as AxiosResponse;
throw error;
});
await expect(service.createComment(commentReq)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create comment at case with id 1. Error: An error has occurred. Reason: Required field'
);
});
test('it should throw if the request is not a JSON', async () => {
requestMock.mockImplementation(() =>
createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } })
);
await expect(service.createComment(commentReq)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create comment at case with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.'
);
});
test('it fails silently if createCommentUrl is missing', async () => {
service = createExternalService(
actionId,
{
config: { ...config, createCommentUrl: '' },
secrets,
},
logger,
configurationUtilities
);
const res = await service.createComment(commentReq);
expect(requestMock).not.toHaveBeenCalled();
expect(res).toBeUndefined();
});
test('it fails silently if createCommentJson is missing', async () => {
service = createExternalService(
actionId,
{
config: { ...config, createCommentJson: '' },
secrets,
},
logger,
configurationUtilities
);
const res = await service.createComment(commentReq);
expect(requestMock).not.toHaveBeenCalled();
expect(res).toBeUndefined();
});
});
describe('bad urls', () => {
beforeAll(() => {
service = createExternalService(
actionId,
{
config,
secrets,
},
logger,
{
...configurationUtilities,
ensureUriAllowed: jest.fn().mockImplementation(() => {
throw new Error('Uri not allowed');
}),
}
);
});
beforeEach(() => {
jest.clearAllMocks();
});
test('getIncident- throws for bad url', async () => {
await expect(service.getIncident('whack')).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to get case with id whack. Error: Invalid Get case URL: Error: error configuring connector action: Uri not allowed.'
);
});
test('createIncident- throws for bad url', async () => {
const incident = {
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create case. Error: Invalid Create case URL: Error: error configuring connector action: Uri not allowed.'
);
});
test('updateIncident- throws for bad url', async () => {
const incident = {
incidentId: '123',
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
await expect(service.updateIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to update case with id 123. Error: Invalid Update case URL: Error: error configuring connector action: Uri not allowed.'
);
});
test('createComment- throws for bad url', async () => {
const commentReq = {
incidentId: '1',
comment: {
comment: 'comment',
commentId: 'comment-1',
},
};
await expect(service.createComment(commentReq)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create comment at case with id 1. Error: Invalid Create comment URL: Error: error configuring connector action: Uri not allowed.'
);
});
});
describe('bad protocol', () => {
beforeAll(() => {
service = createExternalService(
actionId,
{
config: {
...config,
getIncidentUrl: 'ftp://bad.com',
createIncidentUrl: 'ftp://bad.com',
updateIncidentUrl: 'ftp://bad.com',
createCommentUrl: 'ftp://bad.com',
},
secrets,
},
logger,
configurationUtilities
);
});
beforeEach(() => {
jest.clearAllMocks();
});
test('getIncident- throws for bad protocol', async () => {
await expect(service.getIncident('whack')).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to get case with id whack. Error: Invalid Get case URL: Error: Invalid protocol.'
);
});
test('createIncident- throws for bad protocol', async () => {
const incident = {
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
await expect(service.createIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create case. Error: Invalid Create case URL: Error: Invalid protocol.'
);
});
test('updateIncident- throws for bad protocol', async () => {
const incident = {
incidentId: '123',
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
await expect(service.updateIncident(incident)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to update case with id 123. Error: Invalid Update case URL: Error: Invalid protocol.'
);
});
test('createComment- throws for bad protocol', async () => {
const commentReq = {
incidentId: '1',
comment: {
comment: 'comment',
commentId: 'comment-1',
},
};
await expect(service.createComment(commentReq)).rejects.toThrow(
'[Action][Webhook - Case Management]: Unable to create comment at case with id 1. Error: Invalid Create comment URL: Error: Invalid protocol.'
);
});
});
describe('escape urls', () => {
beforeAll(() => {
service = createExternalService(
actionId,
{
config,
secrets,
},
logger,
{
...configurationUtilities,
}
);
requestMock.mockImplementation(() =>
createAxiosResponse({
data: {
id: '../../malicious-app/malicious-endpoint/',
key: '../../malicious-app/malicious-endpoint/',
fields: {
updated: '2020-04-27T10:59:46.202Z',
created: '2020-04-27T10:59:46.202Z',
},
},
})
);
});
beforeEach(() => {
jest.clearAllMocks();
});
test('getIncident- escapes url', async () => {
await service.getIncident('../../malicious-app/malicious-endpoint/');
expect(requestMock.mock.calls[0][0].url).toEqual(
'https://siem-kibana.atlassian.net/rest/api/2/issue/..%2F..%2Fmalicious-app%2Fmalicious-endpoint%2F'
);
});
test('createIncident- escapes url', async () => {
const incident = {
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
const res = await service.createIncident(incident);
expect(res.url).toEqual(
'https://siem-kibana.atlassian.net/browse/..%2F..%2Fmalicious-app%2Fmalicious-endpoint%2F'
);
});
test('updateIncident- escapes url', async () => {
const incident = {
incidentId: '../../malicious-app/malicious-endpoint/',
incident: {
title: 'title',
description: 'desc',
tags: ['hello', 'world'],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
};
await service.updateIncident(incident);
expect(requestMock.mock.calls[0][0].url).toEqual(
'https://siem-kibana.atlassian.net/rest/api/2/issue/..%2F..%2Fmalicious-app%2Fmalicious-endpoint%2F'
);
});
test('createComment- escapes url', async () => {
const commentReq = {
incidentId: '../../malicious-app/malicious-endpoint/',
comment: {
comment: 'comment',
commentId: 'comment-1',
},
};
await service.createComment(commentReq);
expect(requestMock.mock.calls[0][0].url).toEqual(
'https://siem-kibana.atlassian.net/rest/api/2/issue/..%2F..%2Fmalicious-app%2Fmalicious-endpoint%2F/comment'
);
});
});
});

View file

@ -0,0 +1,309 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import axios, { AxiosResponse } from 'axios';
import { Logger } from '@kbn/core/server';
import { isString } from 'lodash';
import { validateAndNormalizeUrl, validateJson } from './validators';
import { renderMustacheStringNoEscape } from '../../lib/mustache_renderer';
import {
createServiceError,
getObjectValueByKeyAsString,
getPushedDate,
stringifyObjValues,
removeSlash,
throwDescriptiveErrorIfResponseIsNotValid,
} from './utils';
import {
CreateIncidentParams,
ExternalServiceCredentials,
ExternalService,
CasesWebhookPublicConfigurationType,
CasesWebhookSecretConfigurationType,
ExternalServiceIncidentResponse,
GetIncidentResponse,
UpdateIncidentParams,
CreateCommentParams,
} from './types';
import * as i18n from './translations';
import { request } from '../lib/axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
export const createExternalService = (
actionId: string,
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
): ExternalService => {
const {
createCommentJson,
createCommentMethod,
createCommentUrl,
createIncidentJson,
createIncidentMethod,
createIncidentResponseKey,
createIncidentUrl: createIncidentUrlConfig,
getIncidentResponseCreatedDateKey,
getIncidentResponseExternalTitleKey,
getIncidentResponseUpdatedDateKey,
getIncidentUrl,
hasAuth,
headers,
incidentViewUrl,
updateIncidentJson,
updateIncidentMethod,
updateIncidentUrl,
} = config as CasesWebhookPublicConfigurationType;
const { password, user } = secrets as CasesWebhookSecretConfigurationType;
if (
!getIncidentUrl ||
!createIncidentUrlConfig ||
!incidentViewUrl ||
!updateIncidentUrl ||
(hasAuth && (!password || !user))
) {
throw Error(`[Action]${i18n.NAME}: Wrong configuration.`);
}
const createIncidentUrl = removeSlash(createIncidentUrlConfig);
const axiosInstance = axios.create({
...(hasAuth && isString(secrets.user) && isString(secrets.password)
? { auth: { username: secrets.user, password: secrets.password } }
: {}),
headers: {
['content-type']: 'application/json',
...(headers != null ? headers : {}),
},
});
const getIncident = async (id: string): Promise<GetIncidentResponse> => {
try {
const getUrl = renderMustacheStringNoEscape(getIncidentUrl, {
external: {
system: {
id: encodeURIComponent(id),
},
},
});
const normalizedUrl = validateAndNormalizeUrl(
`${getUrl}`,
configurationUtilities,
'Get case URL'
);
const res = await request({
axios: axiosInstance,
url: normalizedUrl,
logger,
configurationUtilities,
});
throwDescriptiveErrorIfResponseIsNotValid({
res,
requiredAttributesToBeInTheResponse: [
getIncidentResponseCreatedDateKey,
getIncidentResponseExternalTitleKey,
getIncidentResponseUpdatedDateKey,
],
});
const title = getObjectValueByKeyAsString(res.data, getIncidentResponseExternalTitleKey)!;
const createdAt = getObjectValueByKeyAsString(res.data, getIncidentResponseCreatedDateKey)!;
const updatedAt = getObjectValueByKeyAsString(res.data, getIncidentResponseUpdatedDateKey)!;
return { id, title, createdAt, updatedAt };
} catch (error) {
throw createServiceError(error, `Unable to get case with id ${id}`);
}
};
const createIncident = async ({
incident,
}: CreateIncidentParams): Promise<ExternalServiceIncidentResponse> => {
try {
const { tags, title, description } = incident;
const normalizedUrl = validateAndNormalizeUrl(
`${createIncidentUrl}`,
configurationUtilities,
'Create case URL'
);
const json = renderMustacheStringNoEscape(
createIncidentJson,
stringifyObjValues({
title,
description: description ?? '',
tags: tags ?? [],
})
);
validateJson(json, 'Create case JSON body');
const res: AxiosResponse = await request({
axios: axiosInstance,
url: normalizedUrl,
logger,
method: createIncidentMethod,
data: json,
configurationUtilities,
});
const { status, statusText, data } = res;
throwDescriptiveErrorIfResponseIsNotValid({
res,
requiredAttributesToBeInTheResponse: [createIncidentResponseKey],
});
const externalId = getObjectValueByKeyAsString(data, createIncidentResponseKey)!;
const insertedIncident = await getIncident(externalId);
logger.debug(`response from webhook action "${actionId}": [HTTP ${status}] ${statusText}`);
const viewUrl = renderMustacheStringNoEscape(incidentViewUrl, {
external: {
system: {
id: encodeURIComponent(externalId),
title: encodeURIComponent(insertedIncident.title),
},
},
});
const normalizedViewUrl = validateAndNormalizeUrl(
`${viewUrl}`,
configurationUtilities,
'View case URL'
);
return {
id: externalId,
title: insertedIncident.title,
url: normalizedViewUrl,
pushedDate: getPushedDate(insertedIncident.createdAt),
};
} catch (error) {
throw createServiceError(error, 'Unable to create case');
}
};
const updateIncident = async ({
incidentId,
incident,
}: UpdateIncidentParams): Promise<ExternalServiceIncidentResponse> => {
try {
const updateUrl = renderMustacheStringNoEscape(updateIncidentUrl, {
external: {
system: {
id: encodeURIComponent(incidentId),
},
},
});
const normalizedUrl = validateAndNormalizeUrl(
`${updateUrl}`,
configurationUtilities,
'Update case URL'
);
const { tags, title, description } = incident;
const json = renderMustacheStringNoEscape(updateIncidentJson, {
...stringifyObjValues({
title,
description: description ?? '',
tags: tags ?? [],
}),
external: {
system: {
id: incidentId,
},
},
});
validateJson(json, 'Update case JSON body');
const res = await request({
axios: axiosInstance,
method: updateIncidentMethod,
url: normalizedUrl,
logger,
data: json,
configurationUtilities,
});
throwDescriptiveErrorIfResponseIsNotValid({
res,
});
const updatedIncident = await getIncident(incidentId as string);
const viewUrl = renderMustacheStringNoEscape(incidentViewUrl, {
external: {
system: {
id: encodeURIComponent(incidentId),
title: encodeURIComponent(updatedIncident.title),
},
},
});
const normalizedViewUrl = validateAndNormalizeUrl(
`${viewUrl}`,
configurationUtilities,
'View case URL'
);
return {
id: incidentId,
title: updatedIncident.title,
url: normalizedViewUrl,
pushedDate: getPushedDate(updatedIncident.updatedAt),
};
} catch (error) {
throw createServiceError(error, `Unable to update case with id ${incidentId}`);
}
};
const createComment = async ({ incidentId, comment }: CreateCommentParams): Promise<unknown> => {
try {
if (!createCommentUrl || !createCommentJson || !createCommentMethod) {
return;
}
const commentUrl = renderMustacheStringNoEscape(createCommentUrl, {
external: {
system: {
id: encodeURIComponent(incidentId),
},
},
});
const normalizedUrl = validateAndNormalizeUrl(
`${commentUrl}`,
configurationUtilities,
'Create comment URL'
);
const json = renderMustacheStringNoEscape(createCommentJson, {
...stringifyObjValues({ comment: comment.comment }),
external: {
system: {
id: incidentId,
},
},
});
validateJson(json, 'Create comment JSON body');
const res = await request({
axios: axiosInstance,
method: createCommentMethod,
url: normalizedUrl,
logger,
data: json,
configurationUtilities,
});
throwDescriptiveErrorIfResponseIsNotValid({
res,
});
} catch (error) {
throw createServiceError(error, `Unable to create comment at case with id ${incidentId}`);
}
};
return {
createComment,
createIncident,
getIncident,
updateIncident,
};
};

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const NAME = i18n.translate('xpack.actions.builtin.cases.casesWebhookTitle', {
defaultMessage: 'Webhook - Case Management',
});
export const INVALID_URL = (err: string, url: string) =>
i18n.translate('xpack.actions.builtin.casesWebhook.casesWebhookConfigurationErrorNoHostname', {
defaultMessage: 'error configuring cases webhook action: unable to parse {url}: {err}',
values: {
err,
url,
},
});
export const CONFIG_ERR = (err: string) =>
i18n.translate('xpack.actions.builtin.casesWebhook.casesWebhookConfigurationError', {
defaultMessage: 'error configuring cases webhook action: {err}',
values: {
err,
},
});
export const INVALID_USER_PW = i18n.translate(
'xpack.actions.builtin.casesWebhook.invalidUsernamePassword',
{
defaultMessage: 'both user and password must be specified',
}
);
export const ALLOWED_HOSTS_ERROR = (message: string) =>
i18n.translate('xpack.actions.builtin.casesWebhook.configuration.apiAllowedHostsError', {
defaultMessage: 'error configuring connector action: {message}',
values: {
message,
},
});

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TypeOf } from '@kbn/config-schema';
import { Logger } from '@kbn/core/server';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
ExecutorParamsSchema,
ExecutorSubActionPushParamsSchema,
ExternalIncidentServiceConfigurationSchema,
ExternalIncidentServiceSecretConfigurationSchema,
} from './schema';
// config definition
export const enum CasesWebhookMethods {
PATCH = 'patch',
POST = 'post',
PUT = 'put',
}
// config
export type CasesWebhookPublicConfigurationType = TypeOf<
typeof ExternalIncidentServiceConfigurationSchema
>;
// secrets
export type CasesWebhookSecretConfigurationType = TypeOf<
typeof ExternalIncidentServiceSecretConfigurationSchema
>;
// params
export type CasesWebhookActionParamsType = TypeOf<typeof ExecutorParamsSchema>;
export interface ExternalServiceCredentials {
config: CasesWebhookPublicConfigurationType;
secrets: CasesWebhookSecretConfigurationType;
}
export interface ExternalServiceValidation {
config: (
configurationUtilities: ActionsConfigurationUtilities,
configObject: CasesWebhookPublicConfigurationType
) => void;
secrets: (secrets: CasesWebhookSecretConfigurationType) => void;
connector: (
configObject: CasesWebhookPublicConfigurationType,
secrets: CasesWebhookSecretConfigurationType
) => string | null;
}
export interface ExternalServiceIncidentResponse {
id: string;
title: string;
url: string;
pushedDate: string;
}
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type PushToServiceApiParams = ExecutorSubActionPushParams;
// incident service
export interface ExternalService {
createComment: (params: CreateCommentParams) => Promise<unknown>;
createIncident: (params: CreateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
getIncident: (id: string) => Promise<GetIncidentResponse>;
updateIncident: (params: UpdateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
}
export interface CreateIncidentParams {
incident: Incident;
}
export interface UpdateIncidentParams {
incidentId: string;
incident: Incident;
}
export interface SimpleComment {
comment: string;
commentId: string;
}
export interface CreateCommentParams {
incidentId: string;
comment: SimpleComment;
}
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
}
// incident api
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
logger: Logger;
}
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export interface GetIncidentResponse {
id: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface ExternalServiceApi {
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
}
export type CasesWebhookExecutorResultData = ExternalServiceIncidentResponse;

View file

@ -0,0 +1,162 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
getObjectValueByKeyAsString,
stringifyObjValues,
throwDescriptiveErrorIfResponseIsNotValid,
} from './utils';
const bigOlObject = {
fields: {
id: [
{
good: {
cool: 'cool',
},
},
{
more: [
{
more: {
complicated: 'complicated',
},
},
],
},
],
},
field: {
simple: 'simple',
},
};
describe('cases_webhook/utils', () => {
describe('getObjectValueByKeyAsString()', () => {
it('Handles a simple key', () => {
expect(getObjectValueByKeyAsString(bigOlObject, 'field.simple')).toEqual('simple');
});
it('Handles a complicated key', () => {
expect(getObjectValueByKeyAsString(bigOlObject, 'fields.id[0].good.cool')).toEqual('cool');
});
it('Handles a more complicated key', () => {
expect(
getObjectValueByKeyAsString(bigOlObject, 'fields.id[1].more[0].more.complicated')
).toEqual('complicated');
});
it('Handles a bad key', () => {
expect(getObjectValueByKeyAsString(bigOlObject, 'bad.key')).toEqual(undefined);
});
});
describe('throwDescriptiveErrorIfResponseIsNotValid()', () => {
const res = {
data: bigOlObject,
headers: {},
status: 200,
statusText: 'hooray',
config: {
method: 'post',
url: 'https://poster.com',
},
};
it('Throws error when missing content-type', () => {
expect(() =>
throwDescriptiveErrorIfResponseIsNotValid({
res,
requiredAttributesToBeInTheResponse: ['field.simple'],
})
).toThrow(
'Missing content type header in post https://poster.com. Supported content types: application/json'
);
});
it('Throws error when content-type is not valid', () => {
expect(() =>
throwDescriptiveErrorIfResponseIsNotValid({
res: {
...res,
headers: {
['content-type']: 'not/cool',
},
},
requiredAttributesToBeInTheResponse: ['field.simple'],
})
).toThrow(
'Unsupported content type: not/cool in post https://poster.com. Supported content types: application/json'
);
});
it('Throws error for bad data', () => {
expect(() =>
throwDescriptiveErrorIfResponseIsNotValid({
res: {
...res,
headers: {
['content-type']: 'application/json',
},
data: 'bad',
},
requiredAttributesToBeInTheResponse: ['field.simple'],
})
).toThrow('Response is not a valid JSON');
});
it('Throws for bad key', () => {
expect(() =>
throwDescriptiveErrorIfResponseIsNotValid({
res: {
...res,
headers: {
['content-type']: 'application/json',
},
},
requiredAttributesToBeInTheResponse: ['bad.key'],
})
).toThrow('Response is missing the expected field: bad.key');
});
it('Throws for multiple bad keys', () => {
expect(() =>
throwDescriptiveErrorIfResponseIsNotValid({
res: {
...res,
headers: {
['content-type']: 'application/json',
},
},
requiredAttributesToBeInTheResponse: ['bad.key', 'bad.again'],
})
).toThrow('Response is missing the expected fields: bad.key, bad.again');
});
it('Does not throw for valid key', () => {
expect(() =>
throwDescriptiveErrorIfResponseIsNotValid({
res: {
...res,
headers: {
['content-type']: 'application/json',
},
},
requiredAttributesToBeInTheResponse: ['fields.id[0].good.cool'],
})
).not.toThrow();
});
});
describe('stringifyObjValues()', () => {
const caseObj = {
title: 'title',
description: 'description',
labels: ['cool', 'rad', 'awesome'],
comment: 'comment',
};
it('Handles a case object', () => {
expect(stringifyObjValues(caseObj)).toEqual({
case: {
comment: '"comment"',
description: '"description"',
labels: '["cool","rad","awesome"]',
title: '"title"',
},
});
});
});
});

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AxiosResponse, AxiosError } from 'axios';
import { isEmpty, isObjectLike, get } from 'lodash';
import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils';
import * as i18n from './translations';
export const createServiceError = (error: AxiosError, message: string) => {
const serverResponse =
error.response && error.response.data ? JSON.stringify(error.response.data) : null;
return new Error(
getErrorMessage(
i18n.NAME,
`${message}. Error: ${error.message}. ${serverResponse != null ? serverResponse : ''} ${
error.response?.statusText != null ? `Reason: ${error.response?.statusText}` : ''
}`
)
);
};
export const getPushedDate = (timestamp?: string) => {
if (timestamp != null && new Date(timestamp).getTime() > 0) {
try {
return new Date(timestamp).toISOString();
} catch (e) {
return new Date(addTimeZoneToDate(timestamp)).toISOString();
}
}
return new Date().toISOString();
};
export const getObjectValueByKeyAsString = (
obj: Record<string, Record<string, unknown> | unknown>,
key: string
): string | undefined => {
const value = get(obj, key);
return value === undefined ? value : `${value}`;
};
export const throwDescriptiveErrorIfResponseIsNotValid = ({
res,
requiredAttributesToBeInTheResponse = [],
}: {
res: AxiosResponse;
requiredAttributesToBeInTheResponse?: string[];
}) => {
const requiredContentType = 'application/json';
const contentType = res.headers['content-type'];
const data = res.data;
/**
* Check that the content-type of the response is application/json.
* Then includes is added because the header can be application/json;charset=UTF-8.
*/
if (contentType == null) {
throw new Error(
`Missing content type header in ${res.config.method} ${res.config.url}. Supported content types: ${requiredContentType}`
);
}
if (!contentType.includes(requiredContentType)) {
throw new Error(
`Unsupported content type: ${contentType} in ${res.config.method} ${res.config.url}. Supported content types: ${requiredContentType}`
);
}
if (!isEmpty(data) && !isObjectLike(data)) {
throw new Error('Response is not a valid JSON');
}
if (requiredAttributesToBeInTheResponse.length > 0) {
const requiredAttributesError = (attrs: string[]) =>
new Error(
`Response is missing the expected ${attrs.length > 1 ? `fields` : `field`}: ${attrs.join(
', '
)}`
);
const errorAttributes: string[] = [];
/**
* If the response is an array and requiredAttributesToBeInTheResponse
* are not empty then we throw an error if we are missing data for the given attributes
*/
requiredAttributesToBeInTheResponse.forEach((attr) => {
// Check only for undefined as null is a valid value
if (typeof getObjectValueByKeyAsString(data, attr) === 'undefined') {
errorAttributes.push(attr);
}
});
if (errorAttributes.length) {
throw requiredAttributesError(errorAttributes);
}
}
};
export const removeSlash = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url);
export const stringifyObjValues = (properties: Record<string, string | string[]>) => ({
case: Object.entries(properties).reduce(
(acc, [key, value]) => ({ ...acc, [key]: JSON.stringify(value) }),
{}
),
});

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as i18n from './translations';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
CasesWebhookPublicConfigurationType,
CasesWebhookSecretConfigurationType,
ExternalServiceValidation,
} from './types';
const validateConfig = (
configurationUtilities: ActionsConfigurationUtilities,
configObject: CasesWebhookPublicConfigurationType
) => {
const {
createCommentUrl,
createIncidentUrl,
incidentViewUrl,
getIncidentUrl,
updateIncidentUrl,
} = configObject;
const urls = [
createIncidentUrl,
createCommentUrl,
incidentViewUrl,
getIncidentUrl,
updateIncidentUrl,
];
for (const url of urls) {
if (url) {
try {
new URL(url);
} catch (err) {
return i18n.INVALID_URL(err, url);
}
try {
configurationUtilities.ensureUriAllowed(url);
} catch (allowListError) {
return i18n.CONFIG_ERR(allowListError.message);
}
}
}
};
export const validateConnector = (
configObject: CasesWebhookPublicConfigurationType,
secrets: CasesWebhookSecretConfigurationType
): string | null => {
// user and password must be set together (or not at all)
if (!configObject.hasAuth) return null;
if (secrets.password && secrets.user) return null;
return i18n.INVALID_USER_PW;
};
export const validateSecrets = (secrets: CasesWebhookSecretConfigurationType) => {
// user and password must be set together (or not at all)
if (!secrets.password && !secrets.user) return;
if (secrets.password && secrets.user) return;
return i18n.INVALID_USER_PW;
};
export const validate: ExternalServiceValidation = {
config: validateConfig,
secrets: validateSecrets,
connector: validateConnector,
};
const validProtocols: string[] = ['http:', 'https:'];
export const assertURL = (url: string) => {
try {
const parsedUrl = new URL(url);
if (!parsedUrl.hostname) {
throw new Error(`URL must contain hostname`);
}
if (!validProtocols.includes(parsedUrl.protocol)) {
throw new Error(`Invalid protocol`);
}
} catch (error) {
throw new Error(`${error.message}`);
}
};
export const ensureUriAllowed = (
url: string,
configurationUtilities: ActionsConfigurationUtilities
) => {
try {
configurationUtilities.ensureUriAllowed(url);
} catch (allowedListError) {
throw Error(`${i18n.ALLOWED_HOSTS_ERROR(allowedListError.message)}`);
}
};
export const normalizeURL = (url: string) => {
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const replaceDoubleSlashesRegex = new RegExp('([^:]/)/+', 'g');
return urlWithoutTrailingSlash.replace(replaceDoubleSlashesRegex, '$1');
};
export const validateAndNormalizeUrl = (
url: string,
configurationUtilities: ActionsConfigurationUtilities,
urlDesc: string
) => {
try {
assertURL(url);
ensureUriAllowed(url, configurationUtilities);
return normalizeURL(url);
} catch (e) {
throw Error(`Invalid ${urlDesc}: ${e}`);
}
};
export const validateJson = (jsonString: string, jsonDesc: string) => {
try {
JSON.parse(jsonString);
} catch (e) {
throw new Error(`JSON Error: ${jsonDesc} must be valid JSON`);
}
};

View file

@ -16,6 +16,7 @@ import { getActionType as getSwimlaneActionType } from './swimlane';
import { getActionType as getServerLogActionType } from './server_log';
import { getActionType as getSlackActionType } from './slack';
import { getActionType as getWebhookActionType } from './webhook';
import { getActionType as getCasesWebhookActionType } from './cases_webhook';
import { getActionType as getXmattersActionType } from './xmatters';
import {
getServiceNowITSMActionType,
@ -36,6 +37,8 @@ export { ActionTypeId as ServerLogActionTypeId } from './server_log';
export type { ActionParamsType as SlackActionParams } from './slack';
export { ActionTypeId as SlackActionTypeId } from './slack';
export type { ActionParamsType as WebhookActionParams } from './webhook';
export type { ActionParamsType as CasesWebhookActionParams } from './cases_webhook';
export { ActionTypeId as CasesWebhookActionTypeId } from './cases_webhook';
export { ActionTypeId as WebhookActionTypeId } from './webhook';
export type { ActionParamsType as XmattersActionParams } from './xmatters';
export { ActionTypeId as XmattersActionTypeId } from './xmatters';
@ -72,6 +75,7 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getServerLogActionType({ logger }));
actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getCasesWebhookActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getXmattersActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowITSMActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowSIRActionType({ logger, configurationUtilities }));

View file

@ -26,6 +26,8 @@ export type {
} from './types';
export type {
CasesWebhookActionTypeId,
CasesWebhookActionParams,
EmailActionTypeId,
EmailActionParams,
IndexActionTypeId,

View file

@ -5,7 +5,12 @@
* 2.0.
*/
import { renderMustacheString, renderMustacheObject, Escape } from './mustache_renderer';
import {
renderMustacheString,
renderMustacheStringNoEscape,
renderMustacheObject,
Escape,
} from './mustache_renderer';
const variables = {
a: 1,
@ -120,6 +125,139 @@ describe('mustache_renderer', () => {
);
});
});
describe('renderMustacheStringNoEscape()', () => {
const id = 'cool_id';
const title = 'cool_title';
const summary = 'A cool good summary';
const description = 'A cool good description';
const tags = ['cool', 'neat', 'nice'];
const str = 'https://siem-kibana.atlassian.net/browse/{{{external.system.title}}}';
const objStr =
'{\n' +
'\t"fields": {\n' +
'\t "summary": {{{case.title}}},\n' +
'\t "description": {{{case.description}}},\n' +
'\t "labels": {{{case.tags}}},\n' +
'\t "project":{"key":"ROC"},\n' +
'\t "issuetype":{"id":"10024"}\n' +
'\t}\n' +
'}';
const objStrDouble =
'{\n' +
'\t"fields": {\n' +
'\t "summary": {{case.title}},\n' +
'\t "description": {{case.description}},\n' +
'\t "labels": {{case.tags}},\n' +
'\t "project":{"key":"ROC"},\n' +
'\t "issuetype":{"id":"10024"}\n' +
'\t}\n' +
'}';
const caseVariables = {
case: {
title: summary,
description,
tags,
},
};
const caseVariablesStr = {
case: {
title: JSON.stringify(summary),
description: JSON.stringify(description),
tags: JSON.stringify(tags),
},
};
it('Inserts variables into string without quotes', () => {
const urlVariables = {
external: {
system: {
id,
title,
},
},
};
expect(renderMustacheStringNoEscape(str, urlVariables)).toBe(
`https://siem-kibana.atlassian.net/browse/cool_title`
);
});
it('Inserts variables into url with quotes whens stringified', () => {
const urlVariablesStr = {
external: {
system: {
id: JSON.stringify(id),
title: JSON.stringify(title),
},
},
};
expect(renderMustacheStringNoEscape(str, urlVariablesStr)).toBe(
`https://siem-kibana.atlassian.net/browse/"cool_title"`
);
});
it('Inserts variables into JSON non-escaped when triple brackets and JSON.stringified variables', () => {
expect(renderMustacheStringNoEscape(objStr, caseVariablesStr)).toBe(
`{
\t"fields": {
\t "summary": "A cool good summary",
\t "description": "A cool good description",
\t "labels": ["cool","neat","nice"],
\t "project":{"key":"ROC"},
\t "issuetype":{"id":"10024"}
\t}
}`
);
});
it('Inserts variables into JSON without quotes when triple brackets and NON stringified variables', () => {
expect(renderMustacheStringNoEscape(objStr, caseVariables)).toBe(
`{
\t"fields": {
\t "summary": A cool good summary,
\t "description": A cool good description,
\t "labels": cool,neat,nice,
\t "project":{"key":"ROC"},
\t "issuetype":{"id":"10024"}
\t}
}`
);
});
it('Inserts variables into JSON escaped when double brackets and JSON.stringified variables', () => {
expect(renderMustacheStringNoEscape(objStrDouble, caseVariablesStr)).toBe(
`{
\t"fields": {
\t "summary": &quot;A cool good summary&quot;,
\t "description": &quot;A cool good description&quot;,
\t "labels": [&quot;cool&quot;,&quot;neat&quot;,&quot;nice&quot;],
\t "project":{"key":"ROC"},
\t "issuetype":{"id":"10024"}
\t}
}`
);
});
it('Inserts variables into JSON without quotes when double brackets and NON stringified variables', () => {
expect(renderMustacheStringNoEscape(objStrDouble, caseVariables)).toBe(
`{
\t"fields": {
\t "summary": A cool good summary,
\t "description": A cool good description,
\t "labels": cool,neat,nice,
\t "project":{"key":"ROC"},
\t "issuetype":{"id":"10024"}
\t}
}`
);
});
it('handles errors triple bracket', () => {
expect(renderMustacheStringNoEscape('{{{a}}', variables)).toMatchInlineSnapshot(
`"error rendering mustache template \\"{{{a}}\\": Unclosed tag at 6"`
);
});
it('handles errors double bracket', () => {
expect(renderMustacheStringNoEscape('{{a}', variables)).toMatchInlineSnapshot(
`"error rendering mustache template \\"{{a}\\": Unclosed tag at 4"`
);
});
});
const object = {
literal: 0,

View file

@ -11,6 +11,17 @@ import { isString, isPlainObject, cloneDeepWith } from 'lodash';
export type Escape = 'markdown' | 'slack' | 'json' | 'none';
type Variables = Record<string, unknown>;
// return a rendered mustache template with no escape given the specified variables and escape
// Individual variable values should be stringified already
export function renderMustacheStringNoEscape(string: string, variables: Variables): string {
try {
return Mustache.render(`${string}`, variables);
} catch (err) {
// log error; the mustache code does not currently leak variables
return `error rendering mustache template "${string}": ${err.message}`;
}
}
// return a rendered mustache template given the specified variables and escape
export function renderMustacheString(string: string, variables: Variables, escape: Escape): string {
const augmentedVariables = augmentObjectVariables(variables);

View file

@ -41,6 +41,7 @@ export const ConnectorFieldsRt = rt.union([
]);
export enum ConnectorTypes {
casesWebhook = '.cases-webhook',
jira = '.jira',
none = '.none',
resilient = '.resilient',
@ -49,7 +50,10 @@ export enum ConnectorTypes {
swimlane = '.swimlane',
}
export const connectorTypes = Object.values(ConnectorTypes);
const ConnectorCasesWebhookTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.casesWebhook),
fields: rt.null,
});
const ConnectorJiraTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.jira),
@ -84,6 +88,7 @@ const ConnectorNoneTypeFieldsRt = rt.type({
export const NONE_CONNECTOR_ID: string = 'none';
export const ConnectorTypeFieldsRt = rt.union([
ConnectorCasesWebhookTypeFieldsRt,
ConnectorJiraTypeFieldsRt,
ConnectorNoneTypeFieldsRt,
ConnectorResilientTypeFieldsRt,
@ -96,6 +101,7 @@ export const ConnectorTypeFieldsRt = rt.union([
* This type represents the connector's format when it is encoded within a user action.
*/
export const CaseUserActionConnectorRt = rt.union([
rt.intersection([ConnectorCasesWebhookTypeFieldsRt, rt.type({ name: rt.string })]),
rt.intersection([ConnectorJiraTypeFieldsRt, rt.type({ name: rt.string })]),
rt.intersection([ConnectorNoneTypeFieldsRt, rt.type({ name: rt.string })]),
rt.intersection([ConnectorResilientTypeFieldsRt, rt.type({ name: rt.string })]),
@ -114,6 +120,7 @@ export const CaseConnectorRt = rt.intersection([
export type CaseUserActionConnector = rt.TypeOf<typeof CaseUserActionConnectorRt>;
export type CaseConnector = rt.TypeOf<typeof CaseConnectorRt>;
export type ConnectorTypeFields = rt.TypeOf<typeof ConnectorTypeFieldsRt>;
export type ConnectorCasesWebhookTypeFields = rt.TypeOf<typeof ConnectorCasesWebhookTypeFieldsRt>;
export type ConnectorJiraTypeFields = rt.TypeOf<typeof ConnectorJiraTypeFieldsRt>;
export type ConnectorResilientTypeFields = rt.TypeOf<typeof ConnectorResilientTypeFieldsRt>;
export type ConnectorSwimlaneTypeFields = rt.TypeOf<typeof ConnectorSwimlaneTypeFieldsRt>;

View file

@ -7,7 +7,6 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
export const JiraFieldsRT = rt.type({
issueType: rt.union([rt.string, rt.null]),
priority: rt.union([rt.string, rt.null]),

View file

@ -7,7 +7,6 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
export const ResilientFieldsRT = rt.type({
incidentTypes: rt.union([rt.array(rt.string), rt.null]),
severityCode: rt.union([rt.string, rt.null]),

View file

@ -7,7 +7,6 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
export const ServiceNowITSMFieldsRT = rt.type({
impact: rt.union([rt.string, rt.null]),
severity: rt.union([rt.string, rt.null]),

View file

@ -7,7 +7,6 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
export const ServiceNowSIRFieldsRT = rt.type({
category: rt.union([rt.string, rt.null]),
destIp: rt.union([rt.boolean, rt.null]),

View file

@ -7,7 +7,6 @@
import * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
export const SwimlaneFieldsRT = rt.type({
caseId: rt.union([rt.string, rt.null]),
});

View file

@ -43,7 +43,7 @@ export interface Props {
isLoading: boolean;
mappings: CaseConnectorMapping[];
onChangeConnector: (id: string) => void;
selectedConnector: { id: string; type: string };
selectedConnector: { id: string; type: ConnectorTypes };
updateConnectorDisabled: boolean;
}
const ConnectorsComponent: React.FC<Props> = ({
@ -129,6 +129,7 @@ const ConnectorsComponent: React.FC<Props> = ({
<EuiFlexItem grow={false}>
<Mapping
actionTypeName={actionTypeName}
connectorType={selectedConnector.type}
isLoading={isLoading}
mappings={mappings}
/>

View file

@ -11,10 +11,12 @@ import { mount } from 'enzyme';
import { TestProviders } from '../../common/mock';
import { Mapping, MappingProps } from './mapping';
import { mappings } from './__mock__';
import { ConnectorTypes } from '../../../common/api';
describe('Mapping', () => {
const props: MappingProps = {
actionTypeName: 'ServiceNow ITSM',
connectorType: ConnectorTypes.serviceNowITSM,
isLoading: false,
mappings,
};

View file

@ -10,6 +10,7 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTextColor } from '@elastic/eui';
import { TextColor } from '@elastic/eui/src/components/text/text_color';
import { ConnectorTypes } from '../../../common/api';
import * as i18n from './translations';
import { FieldMapping } from './field_mapping';
@ -17,11 +18,17 @@ import { CaseConnectorMapping } from '../../containers/configure/types';
export interface MappingProps {
actionTypeName: string;
connectorType: ConnectorTypes;
isLoading: boolean;
mappings: CaseConnectorMapping[];
}
const MappingComponent: React.FC<MappingProps> = ({ actionTypeName, isLoading, mappings }) => {
const MappingComponent: React.FC<MappingProps> = ({
actionTypeName,
connectorType,
isLoading,
mappings,
}) => {
const fieldMappingDesc: { desc: string; color: TextColor } = useMemo(
() =>
mappings.length > 0 || isLoading
@ -29,12 +36,18 @@ const MappingComponent: React.FC<MappingProps> = ({ actionTypeName, isLoading, m
desc: i18n.FIELD_MAPPING_DESC(actionTypeName),
color: 'subdued',
}
: connectorType === ConnectorTypes.casesWebhook
? {
desc: i18n.CASES_WEBHOOK_MAPPINGS,
color: 'subdued',
}
: {
desc: i18n.FIELD_MAPPING_DESC_ERR(actionTypeName),
color: 'danger',
},
[isLoading, mappings.length, actionTypeName]
[mappings.length, isLoading, actionTypeName, connectorType]
);
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>

View file

@ -110,13 +110,6 @@ export const FIELD_MAPPING_THIRD_COL = i18n.translate(
}
);
export const FIELD_MAPPING_EDIT_APPEND = i18n.translate(
'xpack.cases.configureCases.fieldMappingEditAppend',
{
defaultMessage: 'Append',
}
);
export const CANCEL = i18n.translate('xpack.cases.configureCases.cancelButton', {
defaultMessage: 'Cancel',
});
@ -125,10 +118,6 @@ export const SAVE = i18n.translate('xpack.cases.configureCases.saveButton', {
defaultMessage: 'Save',
});
export const SAVE_CLOSE = i18n.translate('xpack.cases.configureCases.saveAndCloseButton', {
defaultMessage: 'Save & close',
});
export const WARNING_NO_CONNECTOR_TITLE = i18n.translate(
'xpack.cases.configureCases.warningTitle',
{
@ -139,16 +128,6 @@ export const WARNING_NO_CONNECTOR_TITLE = i18n.translate(
export const COMMENT = i18n.translate('xpack.cases.configureCases.commentMapping', {
defaultMessage: 'Comments',
});
export const REQUIRED_MAPPINGS = (connectorName: string, fields: string): string =>
i18n.translate('xpack.cases.configureCases.requiredMappings', {
values: { connectorName, fields },
defaultMessage:
'At least one Case field needs to be mapped to the following required { connectorName } fields: { fields }',
});
export const UPDATE_FIELD_MAPPINGS = i18n.translate('xpack.cases.configureCases.updateConnector', {
defaultMessage: 'Update field mappings',
});
export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string =>
i18n.translate('xpack.cases.configureCases.updateSelectedConnector', {
@ -173,3 +152,11 @@ export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate(
export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', {
defaultMessage: 'Configure cases',
});
export const CASES_WEBHOOK_MAPPINGS = i18n.translate(
'xpack.cases.configureCases.casesWebhookMappings',
{
defaultMessage:
'Webhook - Case Management field mappings are configured in the connector settings in the third-party REST API JSON.',
}
);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { ConnectorTypes } from '../../../../common/api';
import { ConnectorFieldsProps } from '../types';
import { ConnectorCard } from '../card';
import * as i18n from './translations';
const CasesWebhookComponent: React.FunctionComponent<ConnectorFieldsProps<null>> = ({
connector,
isEdit = true,
}) => (
<>
{!isEdit && (
<>
<ConnectorCard
connectorType={ConnectorTypes.casesWebhook}
isLoading={false}
listItems={[]}
title={connector.name}
/>
<EuiSpacer />
{(!connector.config?.createCommentUrl || !connector.config?.createCommentJson) && (
<EuiCallOut title={i18n.CREATE_COMMENT_WARNING_TITLE} color="warning" iconType="help">
<p>{i18n.CREATE_COMMENT_WARNING_DESC(connector.name)}</p>
</EuiCallOut>
)}
</>
)}
</>
);
CasesWebhookComponent.displayName = 'CasesWebhook';
// eslint-disable-next-line import/no-default-export
export { CasesWebhookComponent as default };

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { CaseConnector } from '../types';
import { ConnectorTypes } from '../../../../common/api';
export const getCaseConnector = (): CaseConnector<null> => {
return {
id: ConnectorTypes.casesWebhook,
fieldsComponent: lazy(() => import('./case_fields')),
};
};

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const CREATE_COMMENT_WARNING_TITLE = i18n.translate(
'xpack.cases.connectors.card.createCommentWarningTitle',
{
defaultMessage: 'Unable to share case comments',
}
);
export const CREATE_COMMENT_WARNING_DESC = (connectorName: string) =>
i18n.translate('xpack.cases.connectors.card.createCommentWarningDesc', {
values: { connectorName },
defaultMessage:
'Configure the Create Comment URL and Create Comment Objects fields for the {connectorName} connector to share comments externally.',
});

View file

@ -10,6 +10,7 @@ import { createCaseConnectorsRegistry } from './connectors_registry';
import { getCaseConnector as getJiraCaseConnector } from './jira';
import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane';
import { getCaseConnector as getResilientCaseConnector } from './resilient';
import { getCaseConnector as getCasesWebhookCaseConnector } from './cases_webhook';
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
import {
JiraFieldsType,
@ -41,6 +42,7 @@ class CaseConnectors {
);
this.caseConnectorsRegistry.register<ServiceNowSIRFieldsType>(getServiceNowSIRCaseConnector());
this.caseConnectorsRegistry.register<SwimlaneFieldsType>(getSwimlaneCaseConnector());
this.caseConnectorsRegistry.register<null>(getCasesWebhookCaseConnector());
}
registry(): CaseConnectorsRegistry {

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Format } from './types';
export const format: Format = (theCase) => {
return {
title: theCase.title,
description: theCase.description,
tags: theCase.tags,
};
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getMapping } from './mapping';
import { format } from './format';
import { CasesWebhookCaseConnector } from './types';
export const getCaseConnector = (): CasesWebhookCaseConnector => ({
getMapping,
format,
});

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { GetMapping } from './types';
// Mappings are done directly in the connector configuration
export const getMapping: GetMapping = () => [];

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ICasesConnector } from '../types';
export type CasesWebhookCaseConnector = ICasesConnector;
export type Format = ICasesConnector['format'];
export type GetMapping = ICasesConnector['getMapping'];

View file

@ -9,10 +9,12 @@ import { ConnectorTypes } from '../../common/api';
import { ICasesConnector, CasesConnectorsMap } from './types';
import { getCaseConnector as getJiraCaseConnector } from './jira';
import { getCaseConnector as getResilientCaseConnector } from './resilient';
import { getCaseConnector as getCasesWebhookCaseConnector } from './cases_webook';
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane';
const mapping: Record<ConnectorTypes, ICasesConnector | null> = {
[ConnectorTypes.casesWebhook]: getCasesWebhookCaseConnector(),
[ConnectorTypes.jira]: getJiraCaseConnector(),
[ConnectorTypes.serviceNowITSM]: getServiceNowITSMCaseConnector(),
[ConnectorTypes.serviceNowSIR]: getServiceNowSIRCaseConnector(),

View file

@ -9931,7 +9931,6 @@
"xpack.cases.configureCases.deprecatedTooltipText": "déclassé",
"xpack.cases.configureCases.fieldMappingDesc": "Mappez les champs des cas aux champs { thirdPartyName } lors de la transmission de données à { thirdPartyName }. Les mappings de champs requièrent une connexion établie à { thirdPartyName }.",
"xpack.cases.configureCases.fieldMappingDescErr": "Impossible de récupérer les mappings pour { thirdPartyName }.",
"xpack.cases.configureCases.fieldMappingEditAppend": "Ajouter",
"xpack.cases.configureCases.fieldMappingFirstCol": "Champ de cas Kibana",
"xpack.cases.configureCases.fieldMappingSecondCol": "Champ { thirdPartyName }",
"xpack.cases.configureCases.fieldMappingThirdCol": "Lors de la modification et de la mise à jour",
@ -9940,10 +9939,7 @@
"xpack.cases.configureCases.incidentManagementSystemDesc": "Connectez vos cas à un système de gestion des incidents externes. Vous pouvez ensuite transmettre les données de cas en tant qu'incident dans un système tiers.",
"xpack.cases.configureCases.incidentManagementSystemLabel": "Système de gestion des incidents",
"xpack.cases.configureCases.incidentManagementSystemTitle": "Système de gestion des incidents externes",
"xpack.cases.configureCases.requiredMappings": "Au moins un champ de cas doit être mappé aux champs { connectorName } requis suivants : { fields }",
"xpack.cases.configureCases.saveAndCloseButton": "Enregistrer et fermer",
"xpack.cases.configureCases.saveButton": "Enregistrer",
"xpack.cases.configureCases.updateConnector": "Mettre à jour les mappings de champs",
"xpack.cases.configureCases.updateSelectedConnector": "Mettre à jour { connectorName }",
"xpack.cases.configureCases.warningMessage": "Le connecteur utilisé pour envoyer des mises à jour au service externe a été supprimé ou vous ne disposez pas de la {appropriateLicense} pour l'utiliser. Pour mettre à jour des cas dans des systèmes externes, sélectionnez un autre connecteur ou créez-en un nouveau.",
"xpack.cases.configureCases.warningTitle": "Avertissement",

View file

@ -9923,7 +9923,6 @@
"xpack.cases.configureCases.deprecatedTooltipText": "廃止予定",
"xpack.cases.configureCases.fieldMappingDesc": "データを{ thirdPartyName }にプッシュするときに、ケースフィールドを{ thirdPartyName }フィールドにマッピングします。フィールドマッピングでは、{ thirdPartyName } への接続を確立する必要があります。",
"xpack.cases.configureCases.fieldMappingDescErr": "{ thirdPartyName }のマッピングを取得できませんでした。",
"xpack.cases.configureCases.fieldMappingEditAppend": "末尾に追加",
"xpack.cases.configureCases.fieldMappingFirstCol": "Kibanaケースフィールド",
"xpack.cases.configureCases.fieldMappingSecondCol": "{ thirdPartyName } フィールド",
"xpack.cases.configureCases.fieldMappingThirdCol": "編集時と更新時",
@ -9932,10 +9931,7 @@
"xpack.cases.configureCases.incidentManagementSystemDesc": "ケースを外部のインシデント管理システムに接続します。その後にサードパーティシステムでケースデータをインシデントとしてプッシュできます。",
"xpack.cases.configureCases.incidentManagementSystemLabel": "インシデント管理システム",
"xpack.cases.configureCases.incidentManagementSystemTitle": "外部インシデント管理システム",
"xpack.cases.configureCases.requiredMappings": "1 つ以上のケースフィールドを次の { connectorName } フィールドにマッピングする必要があります:{ fields }",
"xpack.cases.configureCases.saveAndCloseButton": "保存して閉じる",
"xpack.cases.configureCases.saveButton": "保存",
"xpack.cases.configureCases.updateConnector": "フィールドマッピングを更新",
"xpack.cases.configureCases.updateSelectedConnector": "{ connectorName }を更新",
"xpack.cases.configureCases.warningMessage": "更新を外部サービスに送信するために使用されるコネクターが削除されたか、使用するための{appropriateLicense}がありません。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。",
"xpack.cases.configureCases.warningTitle": "警告",

View file

@ -9938,7 +9938,6 @@
"xpack.cases.configureCases.deprecatedTooltipText": "已过时",
"xpack.cases.configureCases.fieldMappingDesc": "将数据推送到 { thirdPartyName } 时,将案例字段映射到 { thirdPartyName } 字段。字段映射需要与 { thirdPartyName } 建立连接。",
"xpack.cases.configureCases.fieldMappingDescErr": "无法检索 { thirdPartyName } 的映射。",
"xpack.cases.configureCases.fieldMappingEditAppend": "追加",
"xpack.cases.configureCases.fieldMappingFirstCol": "Kibana 案例字段",
"xpack.cases.configureCases.fieldMappingSecondCol": "{ thirdPartyName } 字段",
"xpack.cases.configureCases.fieldMappingThirdCol": "编辑和更新时",
@ -9947,10 +9946,7 @@
"xpack.cases.configureCases.incidentManagementSystemDesc": "将您的案例连接到外部事件管理系统。然后,您便可以将案例数据推送为第三方系统中的事件。",
"xpack.cases.configureCases.incidentManagementSystemLabel": "事件管理系统",
"xpack.cases.configureCases.incidentManagementSystemTitle": "外部事件管理系统",
"xpack.cases.configureCases.requiredMappings": "至少有一个案例字段需要映射到以下所需的 { connectorName } 字段:{ fields }",
"xpack.cases.configureCases.saveAndCloseButton": "保存并关闭",
"xpack.cases.configureCases.saveButton": "保存",
"xpack.cases.configureCases.updateConnector": "更新字段映射",
"xpack.cases.configureCases.updateSelectedConnector": "更新 { connectorName }",
"xpack.cases.configureCases.warningMessage": "用于将更新发送到外部服务的连接器已删除,或您没有{appropriateLicense}来使用它。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。",
"xpack.cases.configureCases.warningTitle": "警告",

View file

@ -126,4 +126,22 @@ describe('AddMessageVariables', () => {
expect(wrapper.find('[data-test-subj="fooAddVariableButton"]')).toHaveLength(0);
});
test('it renders button title when passed', () => {
const wrapper = mountWithIntl(
<AddMessageVariables
messageVariables={[
{
name: 'myVar',
description: 'My variable description',
},
]}
paramsProperty="foo"
onSelectEventHandler={jest.fn()}
showButtonTitle
/>
);
expect(wrapper.find('[data-test-subj="fooAddVariableButton-Title"]').exists()).toEqual(true);
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiPopover,
@ -13,21 +13,26 @@ import {
EuiContextMenuPanel,
EuiContextMenuItem,
EuiText,
EuiButtonEmpty,
} from '@elastic/eui';
import './add_message_variables.scss';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { templateActionVariable } from '../lib';
interface Props {
buttonTitle?: string;
messageVariables?: ActionVariable[];
paramsProperty: string;
onSelectEventHandler: (variable: ActionVariable) => void;
showButtonTitle?: boolean;
}
export const AddMessageVariables: React.FunctionComponent<Props> = ({
buttonTitle,
messageVariables,
paramsProperty,
onSelectEventHandler,
showButtonTitle = false,
}) => {
const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false);
@ -54,20 +59,34 @@ export const AddMessageVariables: React.FunctionComponent<Props> = ({
</EuiContextMenuItem>
));
const addVariableButtonTitle = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle',
{
defaultMessage: 'Add rule variable',
}
);
const addVariableButtonTitle = buttonTitle
? buttonTitle
: i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle',
{
defaultMessage: 'Add rule variable',
}
);
if ((messageVariables?.length ?? 0) === 0) {
return <></>;
}
return (
<EuiPopover
button={
const Button = useMemo(
() =>
showButtonTitle ? (
<EuiButtonEmpty
id={`${paramsProperty}AddVariableButton`}
data-test-subj={`${paramsProperty}AddVariableButton-Title`}
size="xs"
onClick={() => setIsVariablesPopoverOpen(true)}
iconType="indexOpen"
aria-label={i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton',
{
defaultMessage: 'Add variable',
}
)}
>
{addVariableButtonTitle}
</EuiButtonEmpty>
) : (
<EuiButtonIcon
id={`${paramsProperty}AddVariableButton`}
data-test-subj={`${paramsProperty}AddVariableButton`}
@ -81,7 +100,16 @@ export const AddMessageVariables: React.FunctionComponent<Props> = ({
}
)}
/>
}
),
[addVariableButtonTitle, paramsProperty, showButtonTitle]
);
if ((messageVariables?.length ?? 0) === 0) {
return <></>;
}
return (
<EuiPopover
button={Button}
isOpen={isVariablesPopoverOpen}
closePopover={() => setIsVariablesPopoverOpen(false)}
panelPaddingSize="none"

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ActionVariable } from '@kbn/alerting-plugin/common';
import * as i18n from './translations';
export const casesVars: ActionVariable[] = [
{ name: 'case.title', description: i18n.CASE_TITLE_DESC, useWithTripleBracesInTemplates: true },
{
name: 'case.description',
description: i18n.CASE_DESCRIPTION_DESC,
useWithTripleBracesInTemplates: true,
},
{ name: 'case.tags', description: i18n.CASE_TAGS_DESC, useWithTripleBracesInTemplates: true },
];
export const commentVars: ActionVariable[] = [
{
name: 'case.comment',
description: i18n.CASE_COMMENT_DESC,
useWithTripleBracesInTemplates: true,
},
];
export const urlVars: ActionVariable[] = [
{
name: 'external.system.id',
description: i18n.EXTERNAL_ID_DESC,
useWithTripleBracesInTemplates: true,
},
];
export const urlVarsExt: ActionVariable[] = [
...urlVars,
{
name: 'external.system.title',
description: i18n.EXTERNAL_TITLE_DESC,
useWithTripleBracesInTemplates: true,
},
];

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getActionType as getCasesWebhookActionType } from './webhook';

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import {
FIELD_TYPES,
UseArray,
UseField,
useFormContext,
useFormData,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { Field, TextField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { PasswordField } from '../../../password_field';
import * as i18n from '../translations';
const { emptyField } = fieldValidators;
interface Props {
display: boolean;
readOnly: boolean;
}
export const AuthStep: FunctionComponent<Props> = ({ display, readOnly }) => {
const { getFieldDefaultValue } = useFormContext();
const [{ config, __internal__ }] = useFormData({
watch: ['config.hasAuth', '__internal__.hasHeaders'],
});
const hasHeadersDefaultValue = !!getFieldDefaultValue<boolean | undefined>('config.headers');
const hasAuth = config == null ? true : config.hasAuth;
const hasHeaders = __internal__ != null ? __internal__.hasHeaders : false;
return (
<span data-test-subj="authStep" style={{ display: display ? 'block' : 'none' }}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xxs">
<h4>{i18n.AUTH_TITLE}</h4>
</EuiTitle>
<EuiSpacer size="m" />
<UseField
path="config.hasAuth"
component={Field}
config={{ defaultValue: true, type: FIELD_TYPES.TOGGLE }}
componentProps={{
euiFieldProps: {
label: i18n.HAS_AUTH,
disabled: readOnly,
'data-test-subj': 'hasAuthToggle',
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
{hasAuth ? (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<UseField
path="secrets.user"
config={{
label: i18n.USERNAME,
validations: [
{
validator: emptyField(i18n.USERNAME_REQUIRED),
},
],
}}
component={Field}
componentProps={{
euiFieldProps: { readOnly, 'data-test-subj': 'webhookUserInput', fullWidth: true },
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<PasswordField
path="secrets.password"
label={i18n.PASSWORD}
readOnly={readOnly}
data-test-subj="webhookPasswordInput"
/>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
<EuiSpacer size="m" />
<UseField
path="__internal__.hasHeaders"
component={Field}
config={{
defaultValue: hasHeadersDefaultValue,
label: i18n.HEADERS_SWITCH,
type: FIELD_TYPES.TOGGLE,
}}
componentProps={{
euiFieldProps: {
disabled: readOnly,
'data-test-subj': 'webhookViewHeadersSwitch',
},
}}
/>
<EuiSpacer size="m" />
{hasHeaders ? (
<UseArray path="config.headers" initialNumberOfItems={1}>
{({ items, addItem, removeItem }) => {
return (
<>
<EuiTitle size="xxs" data-test-subj="webhookHeaderText">
<h5>{i18n.HEADERS_TITLE}</h5>
</EuiTitle>
<EuiSpacer size="s" />
{items.map((item) => (
<EuiFlexGroup key={item.id}>
<EuiFlexItem>
<UseField
path={`${item.path}.key`}
config={{
label: i18n.KEY_LABEL,
}}
component={TextField}
// This is needed because when you delete
// a row and add a new one, the stale values will appear
readDefaultValueOnForm={!item.isNew}
componentProps={{
euiFieldProps: { readOnly, ['data-test-subj']: 'webhookHeadersKeyInput' },
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.value`}
config={{ label: i18n.VALUE_LABEL }}
component={TextField}
readDefaultValueOnForm={!item.isNew}
componentProps={{
euiFieldProps: {
readOnly,
['data-test-subj']: 'webhookHeadersValueInput',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label={i18n.DELETE_BUTTON}
style={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
))}
<EuiSpacer size="m" />
<EuiButtonEmpty
iconType="plusInCircle"
onClick={addItem}
data-test-subj="webhookAddHeaderButton"
>
{i18n.ADD_BUTTON}
</EuiButtonEmpty>
<EuiSpacer />
</>
);
}}
</UseArray>
) : null}
</span>
);
};

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { FIELD_TYPES, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { containsTitleAndDesc } from '../validator';
import { JsonFieldWrapper } from '../../../json_field_wrapper';
import { casesVars } from '../action_variables';
import { HTTP_VERBS } from '../webhook_connectors';
import * as i18n from '../translations';
const { emptyField, urlField } = fieldValidators;
interface Props {
display: boolean;
readOnly: boolean;
}
export const CreateStep: FunctionComponent<Props> = ({ display, readOnly }) => (
<span data-test-subj="createStep" style={{ display: display ? 'block' : 'none' }}>
<EuiText>
<h3>{i18n.STEP_2}</h3>
<small>
<p>{i18n.STEP_2_DESCRIPTION}</p>
</small>
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<UseField
path="config.createIncidentMethod"
component={Field}
config={{
label: i18n.CREATE_INCIDENT_METHOD,
defaultValue: 'post',
type: FIELD_TYPES.SELECT,
validations: [
{
validator: emptyField(i18n.CREATE_METHOD_REQUIRED),
},
],
}}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookCreateMethodSelect',
options: HTTP_VERBS.map((verb) => ({ text: verb.toUpperCase(), value: verb })),
readOnly,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.createIncidentUrl"
config={{
label: i18n.CREATE_INCIDENT_URL,
validations: [
{
validator: urlField(i18n.CREATE_URL_REQUIRED),
},
],
}}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'webhookCreateUrlText',
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="config.createIncidentJson"
config={{
helpText: i18n.CREATE_INCIDENT_JSON_HELP,
label: i18n.CREATE_INCIDENT_JSON,
validations: [
{
validator: emptyField(i18n.CREATE_INCIDENT_REQUIRED),
},
{
validator: containsTitleAndDesc(),
},
],
}}
component={JsonFieldWrapper}
componentProps={{
euiCodeEditorProps: {
isReadOnly: readOnly,
'data-test-subj': 'webhookCreateIncidentJson',
['aria-label']: i18n.CODE_EDITOR,
},
messageVariables: casesVars,
paramsProperty: 'createIncidentJson',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="config.createIncidentResponseKey"
config={{
helpText: i18n.CREATE_INCIDENT_RESPONSE_KEY_HELP,
label: i18n.CREATE_INCIDENT_RESPONSE_KEY,
validations: [
{
validator: emptyField(i18n.CREATE_RESPONSE_KEY_REQUIRED),
},
],
}}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'createIncidentResponseKeyText',
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</span>
);

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { MustacheTextFieldWrapper } from '../../../mustache_text_field_wrapper';
import { containsExternalId, containsExternalIdOrTitle } from '../validator';
import { urlVars, urlVarsExt } from '../action_variables';
import * as i18n from '../translations';
const { emptyField, urlField } = fieldValidators;
interface Props {
display: boolean;
readOnly: boolean;
}
export const GetStep: FunctionComponent<Props> = ({ display, readOnly }) => (
<span data-test-subj="getStep" style={{ display: display ? 'block' : 'none' }}>
<EuiText>
<h3>{i18n.STEP_3}</h3>
<small>
<p>{i18n.STEP_3_DESCRIPTION}</p>
</small>
</EuiText>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<UseField
path="config.getIncidentUrl"
config={{
label: i18n.GET_INCIDENT_URL,
validations: [
{
validator: urlField(i18n.GET_INCIDENT_URL_REQUIRED),
},
{ validator: containsExternalId() },
],
helpText: i18n.GET_INCIDENT_URL_HELP,
}}
component={MustacheTextFieldWrapper}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'webhookGetUrlText',
messageVariables: urlVars,
paramsProperty: 'getIncidentUrl',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.getIncidentResponseExternalTitleKey"
config={{
label: i18n.GET_INCIDENT_TITLE_KEY,
validations: [
{
validator: emptyField(i18n.GET_RESPONSE_EXTERNAL_TITLE_KEY_REQUIRED),
},
],
helpText: i18n.GET_INCIDENT_TITLE_KEY_HELP,
}}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'getIncidentResponseExternalTitleKeyText',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.getIncidentResponseCreatedDateKey"
config={{
label: i18n.GET_INCIDENT_CREATED_KEY,
validations: [
{
validator: emptyField(i18n.GET_RESPONSE_EXTERNAL_CREATED_KEY_REQUIRED),
},
],
helpText: i18n.GET_INCIDENT_CREATED_KEY_HELP,
}}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'getIncidentResponseCreatedDateKeyText',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.getIncidentResponseUpdatedDateKey"
config={{
label: i18n.GET_INCIDENT_UPDATED_KEY,
validations: [
{
validator: emptyField(i18n.GET_RESPONSE_EXTERNAL_UPDATED_KEY_REQUIRED),
},
],
helpText: i18n.GET_INCIDENT_UPDATED_KEY_HELP,
}}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'getIncidentResponseUpdatedDateKeyText',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.incidentViewUrl"
config={{
label: i18n.EXTERNAL_INCIDENT_VIEW_URL,
validations: [
{
validator: urlField(i18n.GET_INCIDENT_VIEW_URL_REQUIRED),
},
{ validator: containsExternalIdOrTitle() },
],
helpText: i18n.EXTERNAL_INCIDENT_VIEW_URL_HELP,
}}
component={MustacheTextFieldWrapper}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'incidentViewUrlText',
messageVariables: urlVarsExt,
paramsProperty: 'incidentViewUrl',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</span>
);

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './auth';
export * from './create';
export * from './get';
export * from './update';

View file

@ -0,0 +1,205 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { FIELD_TYPES, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { containsCommentsOrEmpty, containsTitleAndDesc, isUrlButCanBeEmpty } from '../validator';
import { MustacheTextFieldWrapper } from '../../../mustache_text_field_wrapper';
import { casesVars, commentVars, urlVars } from '../action_variables';
import { JsonFieldWrapper } from '../../../json_field_wrapper';
import { HTTP_VERBS } from '../webhook_connectors';
import * as i18n from '../translations';
const { emptyField, urlField } = fieldValidators;
interface Props {
display: boolean;
readOnly: boolean;
}
export const UpdateStep: FunctionComponent<Props> = ({ display, readOnly }) => (
<span data-test-subj="updateStep" style={{ display: display ? 'block' : 'none' }}>
<EuiText>
<h3>{i18n.STEP_4A}</h3>
<small>
<p>{i18n.STEP_4A_DESCRIPTION}</p>
</small>
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<UseField
path="config.updateIncidentMethod"
component={Field}
config={{
label: i18n.UPDATE_INCIDENT_METHOD,
defaultValue: 'put',
type: FIELD_TYPES.SELECT,
validations: [
{
validator: emptyField(i18n.UPDATE_METHOD_REQUIRED),
},
],
}}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookUpdateMethodSelect',
options: HTTP_VERBS.map((verb) => ({ text: verb.toUpperCase(), value: verb })),
readOnly,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.updateIncidentUrl"
config={{
label: i18n.UPDATE_INCIDENT_URL,
validations: [
{
validator: urlField(i18n.UPDATE_URL_REQUIRED),
},
],
helpText: i18n.UPDATE_INCIDENT_URL_HELP,
}}
component={MustacheTextFieldWrapper}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'webhookUpdateUrlText',
messageVariables: urlVars,
paramsProperty: 'updateIncidentUrl',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="config.updateIncidentJson"
config={{
helpText: i18n.UPDATE_INCIDENT_JSON_HELP,
label: i18n.UPDATE_INCIDENT_JSON,
validations: [
{
validator: emptyField(i18n.UPDATE_INCIDENT_REQUIRED),
},
{
validator: containsTitleAndDesc(),
},
],
}}
component={JsonFieldWrapper}
componentProps={{
euiCodeEditorProps: {
height: '200px',
isReadOnly: readOnly,
'data-test-subj': 'webhookUpdateIncidentJson',
['aria-label']: i18n.CODE_EDITOR,
},
messageVariables: [...casesVars, ...urlVars],
paramsProperty: 'updateIncidentJson',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiText>
<h3>{i18n.STEP_4B}</h3>
<small>
<p>{i18n.STEP_4B_DESCRIPTION}</p>
</small>
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<UseField
path="config.createCommentMethod"
component={Field}
config={{
label: i18n.CREATE_COMMENT_METHOD,
defaultValue: 'put',
type: FIELD_TYPES.SELECT,
validations: [
{
validator: emptyField(i18n.CREATE_COMMENT_METHOD_REQUIRED),
},
],
}}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookCreateCommentMethodSelect',
options: HTTP_VERBS.map((verb) => ({ text: verb.toUpperCase(), value: verb })),
readOnly,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.createCommentUrl"
config={{
label: i18n.CREATE_COMMENT_URL,
validations: [
{
validator: isUrlButCanBeEmpty(i18n.CREATE_COMMENT_URL_REQUIRED),
},
],
helpText: i18n.CREATE_COMMENT_URL_HELP,
}}
component={MustacheTextFieldWrapper}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'webhookCreateCommentUrlText',
messageVariables: urlVars,
paramsProperty: 'createCommentUrl',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="config.createCommentJson"
config={{
helpText: i18n.CREATE_COMMENT_JSON_HELP,
label: i18n.CREATE_COMMENT_JSON,
validations: [
{
validator: containsCommentsOrEmpty(i18n.CREATE_COMMENT_MESSAGE),
},
],
}}
component={JsonFieldWrapper}
componentProps={{
euiCodeEditorProps: {
height: '200px',
isReadOnly: readOnly,
'data-test-subj': 'webhookCreateCommentJson',
['aria-label']: i18n.CODE_EDITOR,
},
messageVariables: [...commentVars, ...urlVars],
paramsProperty: 'createCommentJson',
buttonTitle: i18n.ADD_CASES_VARIABLE,
showButtonTitle: true,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</span>
);

View file

@ -0,0 +1,536 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const CREATE_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredCreateUrlText',
{
defaultMessage: 'Create case URL is required.',
}
);
export const CREATE_INCIDENT_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredCreateIncidentText',
{
defaultMessage: 'Create case object is required and must be valid JSON.',
}
);
export const CREATE_METHOD_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredCreateMethodText',
{
defaultMessage: 'Create case method is required.',
}
);
export const CREATE_RESPONSE_KEY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredCreateIncidentResponseKeyText',
{
defaultMessage: 'Create case response case id key is required.',
}
);
export const UPDATE_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredUpdateUrlText',
{
defaultMessage: 'Update case URL is required.',
}
);
export const UPDATE_INCIDENT_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredUpdateIncidentText',
{
defaultMessage: 'Update case object is required and must be valid JSON.',
}
);
export const UPDATE_METHOD_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredUpdateMethodText',
{
defaultMessage: 'Update case method is required.',
}
);
export const CREATE_COMMENT_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredCreateCommentUrlText',
{
defaultMessage: 'Create comment URL must be URL format.',
}
);
export const CREATE_COMMENT_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredCreateCommentIncidentText',
{
defaultMessage: 'Create comment object must be valid JSON.',
}
);
export const CREATE_COMMENT_METHOD_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredCreateCommentMethodText',
{
defaultMessage: 'Create comment method is required.',
}
);
export const GET_INCIDENT_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.requiredGetIncidentUrlText',
{
defaultMessage: 'Get case URL is required.',
}
);
export const GET_RESPONSE_EXTERNAL_TITLE_KEY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredGetIncidentResponseExternalTitleKeyText',
{
defaultMessage: 'Get case response external case title key is re quired.',
}
);
export const GET_RESPONSE_EXTERNAL_CREATED_KEY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredGetIncidentResponseCreatedKeyText',
{
defaultMessage: 'Get case response created date key is required.',
}
);
export const GET_RESPONSE_EXTERNAL_UPDATED_KEY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredGetIncidentResponseUpdatedKeyText',
{
defaultMessage: 'Get case response updated date key is required.',
}
);
export const GET_INCIDENT_VIEW_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredGetIncidentViewUrlKeyText',
{
defaultMessage: 'View case URL is required.',
}
);
export const MISSING_VARIABLES = (variables: string[]) =>
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.error.missingVariables',
{
defaultMessage:
'Missing required {variableCount, plural, one {variable} other {variables}}: {variables}',
values: { variableCount: variables.length, variables: variables.join(', ') },
}
);
export const USERNAME_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.casesWebhookAction.error.requiredAuthUserNameText',
{
defaultMessage: 'Username is required.',
}
);
export const SUMMARY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookSummaryText',
{
defaultMessage: 'Title is required.',
}
);
export const KEY_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.keyTextFieldLabel',
{
defaultMessage: 'Key',
}
);
export const VALUE_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.valueTextFieldLabel',
{
defaultMessage: 'Value',
}
);
export const ADD_BUTTON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.addHeaderButton',
{
defaultMessage: 'Add',
}
);
export const DELETE_BUTTON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.deleteHeaderButton',
{
defaultMessage: 'Delete',
description: 'Delete HTTP header',
}
);
export const CREATE_INCIDENT_METHOD = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createIncidentMethodTextFieldLabel',
{
defaultMessage: 'Create Case Method',
}
);
export const CREATE_INCIDENT_URL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createIncidentUrlTextFieldLabel',
{
defaultMessage: 'Create Case URL',
}
);
export const CREATE_INCIDENT_JSON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createIncidentJsonTextFieldLabel',
{
defaultMessage: 'Create Case Object',
}
);
export const CREATE_INCIDENT_JSON_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createIncidentJsonHelpText',
{
defaultMessage:
'JSON object to create case. Use the variable selector to add Cases data to the payload.',
}
);
export const JSON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.jsonFieldLabel',
{
defaultMessage: 'JSON',
}
);
export const CODE_EDITOR = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.jsonCodeEditorAriaLabel',
{
defaultMessage: 'Code editor',
}
);
export const CREATE_INCIDENT_RESPONSE_KEY = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createIncidentResponseKeyTextFieldLabel',
{
defaultMessage: 'Create Case Response Case Key',
}
);
export const CREATE_INCIDENT_RESPONSE_KEY_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createIncidentResponseKeyHelpText',
{
defaultMessage: 'JSON key in create case response that contains the external case id',
}
);
export const ADD_CASES_VARIABLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.addVariable',
{
defaultMessage: 'Add variable',
}
);
export const GET_INCIDENT_URL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentUrlTextFieldLabel',
{
defaultMessage: 'Get Case URL',
}
);
export const GET_INCIDENT_URL_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentUrlHelp',
{
defaultMessage:
'API URL to GET case details JSON from external system. Use the variable selector to add external system id to the url.',
}
);
export const GET_INCIDENT_TITLE_KEY = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentResponseExternalTitleKeyTextFieldLabel',
{
defaultMessage: 'Get Case Response External Title Key',
}
);
export const GET_INCIDENT_TITLE_KEY_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentResponseExternalTitleKeyHelp',
{
defaultMessage: 'JSON key in get case response that contains the external case title',
}
);
export const GET_INCIDENT_CREATED_KEY = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentResponseCreatedDateKeyTextFieldLabel',
{
defaultMessage: 'Get Case Response Created Date Key',
}
);
export const GET_INCIDENT_CREATED_KEY_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentResponseCreatedDateKeyHelp',
{
defaultMessage: 'JSON key in get case response that contains the date the case was created.',
}
);
export const GET_INCIDENT_UPDATED_KEY = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentResponseUpdatedDateKeyTextFieldLabel',
{
defaultMessage: 'Get Case Response Updated Date Key',
}
);
export const GET_INCIDENT_UPDATED_KEY_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.getIncidentResponseUpdatedDateKeyHelp',
{
defaultMessage: 'JSON key in get case response that contains the date the case was updated.',
}
);
export const EXTERNAL_INCIDENT_VIEW_URL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.incidentViewUrlTextFieldLabel',
{
defaultMessage: 'External Case View URL',
}
);
export const EXTERNAL_INCIDENT_VIEW_URL_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.incidentViewUrlHelp',
{
defaultMessage:
'URL to view case in external system. Use the variable selector to add external system id or external system title to the url.',
}
);
export const UPDATE_INCIDENT_METHOD = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.updateIncidentMethodTextFieldLabel',
{
defaultMessage: 'Update Case Method',
}
);
export const UPDATE_INCIDENT_URL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.updateIncidentUrlTextFieldLabel',
{
defaultMessage: 'Update Case URL',
}
);
export const UPDATE_INCIDENT_URL_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.updateIncidentUrlHelp',
{
defaultMessage: 'API URL to update case.',
}
);
export const UPDATE_INCIDENT_JSON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.updateIncidentJsonTextFieldLabel',
{
defaultMessage: 'Update Case Object',
}
);
export const UPDATE_INCIDENT_JSON_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.updateIncidentJsonHelpl',
{
defaultMessage:
'JSON object to update case. Use the variable selector to add Cases data to the payload.',
}
);
export const CREATE_COMMENT_METHOD = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createCommentMethodTextFieldLabel',
{
defaultMessage: 'Create Comment Method',
}
);
export const CREATE_COMMENT_URL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createCommentUrlTextFieldLabel',
{
defaultMessage: 'Create Comment URL',
}
);
export const CREATE_COMMENT_URL_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createCommentUrlHelp',
{
defaultMessage: 'API URL to add comment to case.',
}
);
export const CREATE_COMMENT_JSON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createCommentJsonTextFieldLabel',
{
defaultMessage: 'Create Comment Object',
}
);
export const CREATE_COMMENT_JSON_HELP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.createCommentJsonHelp',
{
defaultMessage:
'JSON object to create a comment. Use the variable selector to add Cases data to the payload.',
}
);
export const HAS_AUTH = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.hasAuthSwitchLabel',
{
defaultMessage: 'Require authentication for this webhook',
}
);
export const USERNAME = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.userTextFieldLabel',
{
defaultMessage: 'Username',
}
);
export const PASSWORD = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.passwordTextFieldLabel',
{
defaultMessage: 'Password',
}
);
export const HEADERS_SWITCH = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.viewHeadersSwitch',
{
defaultMessage: 'Add HTTP header',
}
);
export const HEADERS_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.httpHeadersTitle',
{
defaultMessage: 'Headers in use',
}
);
export const AUTH_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.authenticationLabel',
{
defaultMessage: 'Authentication',
}
);
export const STEP_1 = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step1',
{
defaultMessage: 'Set up connector',
}
);
export const STEP_2 = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step2',
{
defaultMessage: 'Create case',
}
);
export const STEP_2_DESCRIPTION = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step2Description',
{
defaultMessage:
'Set fields to create the case in the external system. Check your services API documentation to understand what fields are required',
}
);
export const STEP_3 = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step3',
{
defaultMessage: 'Get case information',
}
);
export const STEP_3_DESCRIPTION = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step3Description',
{
defaultMessage:
'Set fields to add comments to the case in external system. For some systems, this may be the same method as creating updates in cases. Check your services API documentation to understand what fields are required.',
}
);
export const STEP_4 = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step4',
{
defaultMessage: 'Comments and updates',
}
);
export const STEP_4A = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step4a',
{
defaultMessage: 'Create update in case',
}
);
export const STEP_4A_DESCRIPTION = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step4aDescription',
{
defaultMessage:
'Set fields to create updates to the case in external system. For some systems, this may be the same method as adding comments to cases.',
}
);
export const STEP_4B = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step4b',
{
defaultMessage: 'Add comment in case',
}
);
export const STEP_4B_DESCRIPTION = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.step4bDescription',
{
defaultMessage:
'Set fields to add comments to the case in external system. For some systems, this may be the same method as creating updates in cases.',
}
);
export const NEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.next',
{
defaultMessage: 'Next',
}
);
export const PREVIOUS = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.previous',
{
defaultMessage: 'Previous',
}
);
export const CASE_TITLE_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.caseTitleDesc',
{
defaultMessage: 'Kibana case title',
}
);
export const CASE_DESCRIPTION_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.caseDescriptionDesc',
{
defaultMessage: 'Kibana case description',
}
);
export const CASE_TAGS_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.caseTagsDesc',
{
defaultMessage: 'Kibana case tags',
}
);
export const CASE_COMMENT_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.caseCommentDesc',
{
defaultMessage: 'Kibana case comment',
}
);
export const EXTERNAL_ID_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.externalIdDesc',
{
defaultMessage: 'External system id',
}
);
export const EXTERNAL_TITLE_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.externalTitleDesc',
{
defaultMessage: 'External system title',
}
);
export const DOC_LINK = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.docLink',
{
defaultMessage: 'Configuring Webhook - Case Management connector.',
}
);

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import {
CasesWebhookPublicConfigurationType,
CasesWebhookSecretConfigurationType,
ExecutorSubActionPushParams,
} from '@kbn/actions-plugin/server/builtin_action_types/cases_webhook/types';
import { UserConfiguredActionConnector } from '../../../../types';
export interface CasesWebhookActionParams {
subAction: string;
subActionParams: ExecutorSubActionPushParams;
}
export type CasesWebhookConfig = CasesWebhookPublicConfigurationType;
export type CasesWebhookSecrets = CasesWebhookSecretConfigurationType;
export type CasesWebhookActionConnector = UserConfiguredActionConnector<
CasesWebhookConfig,
CasesWebhookSecrets
>;

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ERROR_CODE } from '@kbn/es-ui-shared-plugin/static/forms/helpers/field_validators/types';
import {
ValidationError,
ValidationFunc,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { containsChars, isUrl } from '@kbn/es-ui-shared-plugin/static/validators/string';
import * as i18n from './translations';
import { casesVars, commentVars, urlVars, urlVarsExt } from './action_variables';
import { templateActionVariable } from '../../../lib';
const errorCode: ERROR_CODE = 'ERR_FIELD_MISSING';
const missingVariableErrorMessage = (path: string, variables: string[]) => ({
code: errorCode,
path,
message: i18n.MISSING_VARIABLES(variables),
});
export const containsTitleAndDesc =
() =>
(...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value, path }] = args;
const title = templateActionVariable(
casesVars.find((actionVariable) => actionVariable.name === 'case.title')!
);
const description = templateActionVariable(
casesVars.find((actionVariable) => actionVariable.name === 'case.description')!
);
const varsWithErrors = [title, description].filter(
(variable) => !containsChars(variable)(value as string).doesContain
);
if (varsWithErrors.length > 0) {
return missingVariableErrorMessage(path, varsWithErrors);
}
};
export const containsExternalId =
() =>
(...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value, path }] = args;
const id = templateActionVariable(
urlVars.find((actionVariable) => actionVariable.name === 'external.system.id')!
);
return containsChars(id)(value as string).doesContain
? undefined
: missingVariableErrorMessage(path, [id]);
};
export const containsExternalIdOrTitle =
() =>
(...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value, path }] = args;
const id = templateActionVariable(
urlVars.find((actionVariable) => actionVariable.name === 'external.system.id')!
);
const title = templateActionVariable(
urlVarsExt.find((actionVariable) => actionVariable.name === 'external.system.title')!
);
const error = missingVariableErrorMessage(path, [id, title]);
if (typeof value === 'string') {
const { doesContain: doesContainId } = containsChars(id)(value);
const { doesContain: doesContainTitle } = containsChars(title)(value);
if (doesContainId || doesContainTitle) {
return undefined;
}
}
return error;
};
export const containsCommentsOrEmpty =
(message: string) =>
(...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value, path }] = args;
if (typeof value !== 'string') {
return {
code: 'ERR_FIELD_FORMAT',
formatType: 'STRING',
message,
};
}
if (value.length === 0) {
return undefined;
}
const comment = templateActionVariable(
commentVars.find((actionVariable) => actionVariable.name === 'case.comment')!
);
let error;
if (typeof value === 'string') {
const { doesContain } = containsChars(comment)(value);
if (!doesContain) {
error = missingVariableErrorMessage(path, [comment]);
}
}
return error;
};
export const isUrlButCanBeEmpty =
(message: string) =>
(...args: Parameters<ValidationFunc>) => {
const [{ value }] = args;
const error: ValidationError<ERROR_CODE> = {
code: 'ERR_FIELD_FORMAT',
formatType: 'URL',
message,
};
if (typeof value !== 'string') {
return error;
}
if (value.length === 0) {
return undefined;
}
return isUrl(value) ? undefined : error;
};

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '..';
import { ActionTypeModel } from '../../../../types';
import { registrationServicesMock } from '../../../../mocks';
const ACTION_TYPE_ID = '.cases-webhook';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
test('action type static data is as expected', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
expect(actionTypeModel.iconClass).toEqual('logoWebhook');
});
});
describe('webhook action params validation', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
subActionParams: { incident: { title: 'some title {{test}}' }, comments: [] },
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { 'subActionParams.incident.title': [] },
});
});
test('params validation fails when body is not valid', async () => {
const actionParams = {
subActionParams: { incident: { title: '' }, comments: [] },
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.title': ['Title is required.'],
},
});
});
});

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ActionTypeModel, GenericValidationResult } from '../../../../types';
import { CasesWebhookActionParams, CasesWebhookConfig, CasesWebhookSecrets } from './types';
export function getActionType(): ActionTypeModel<
CasesWebhookConfig,
CasesWebhookSecrets,
CasesWebhookActionParams
> {
return {
id: '.cases-webhook',
iconClass: 'logoWebhook',
selectMessage: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.selectMessageText',
{
defaultMessage: 'Send a request to a Case Management web service.',
}
),
isExperimental: true,
actionTypeTitle: i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhookAction.actionTypeTitle',
{
defaultMessage: 'Webhook - Case Management data',
}
),
validateParams: async (
actionParams: CasesWebhookActionParams
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
'subActionParams.incident.title': new Array<string>(),
};
const validationResult = { errors };
if (
actionParams.subActionParams &&
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.title?.length
) {
errors['subActionParams.incident.title'].push(translations.SUMMARY_REQUIRED);
}
return validationResult;
},
actionConnectorFields: lazy(() => import('./webhook_connectors')),
actionParamsFields: lazy(() => import('./webhook_params')),
};
}

View file

@ -0,0 +1,559 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import CasesWebhookActionConnectorFields from './webhook_connectors';
import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../test_utils';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MockCodeEditor } from '../../../code_editor.mock';
import * as i18n from './translations';
const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public';
jest.mock('../../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../../common/lib/kibana');
return {
...originalModule,
useKibana: () => ({
services: {
docLinks: { ELASTIC_WEBSITE_URL: 'url' },
},
}),
};
});
jest.mock(kibanaReactPath, () => {
const original = jest.requireActual(kibanaReactPath);
return {
...original,
CodeEditor: (props: any) => {
return <MockCodeEditor {...props} />;
},
};
});
const invalidJsonTitle = `{"fields":{"summary":"wrong","description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}`;
const invalidJsonBoth = `{"fields":{"summary":"wrong","description":"wrong","project":{"key":"ROC"},"issuetype":{"id":"10024"}}}`;
const config = {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: 'post',
createCommentUrl:
'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: 'post',
createIncidentResponseKey: 'id',
createIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: [{ key: 'content-type', value: 'text' }],
incidentViewUrl: 'https://siem-kibana.atlassian.net/browse/{{{external.system.title}}}',
getIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: 'put',
updateIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
};
const actionConnector = {
secrets: {
user: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.cases-webhook',
isDeprecated: false,
isPreconfigured: false,
name: 'cases webhook',
config,
};
describe('CasesWebhookActionConnectorFields renders', () => {
test('All inputs are properly rendered', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(getByTestId('webhookUserInput')).toBeInTheDocument();
expect(getByTestId('webhookPasswordInput')).toBeInTheDocument();
expect(getByTestId('webhookHeadersKeyInput')).toBeInTheDocument();
expect(getByTestId('webhookHeadersValueInput')).toBeInTheDocument();
expect(getByTestId('webhookCreateMethodSelect')).toBeInTheDocument();
expect(getByTestId('webhookCreateUrlText')).toBeInTheDocument();
expect(getByTestId('webhookCreateIncidentJson')).toBeInTheDocument();
expect(getByTestId('createIncidentResponseKeyText')).toBeInTheDocument();
expect(getByTestId('getIncidentUrlInput')).toBeInTheDocument();
expect(getByTestId('getIncidentResponseExternalTitleKeyText')).toBeInTheDocument();
expect(getByTestId('getIncidentResponseCreatedDateKeyText')).toBeInTheDocument();
expect(getByTestId('getIncidentResponseUpdatedDateKeyText')).toBeInTheDocument();
expect(getByTestId('incidentViewUrlInput')).toBeInTheDocument();
expect(getByTestId('webhookUpdateMethodSelect')).toBeInTheDocument();
expect(getByTestId('updateIncidentUrlInput')).toBeInTheDocument();
expect(getByTestId('webhookUpdateIncidentJson')).toBeInTheDocument();
expect(getByTestId('webhookCreateCommentMethodSelect')).toBeInTheDocument();
expect(getByTestId('createCommentUrlInput')).toBeInTheDocument();
expect(getByTestId('webhookCreateCommentJson')).toBeInTheDocument();
});
test('Toggles work properly', async () => {
const { getByTestId, queryByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(getByTestId('hasAuthToggle')).toHaveAttribute('aria-checked', 'true');
await act(async () => {
userEvent.click(getByTestId('hasAuthToggle'));
});
expect(getByTestId('hasAuthToggle')).toHaveAttribute('aria-checked', 'false');
expect(queryByTestId('webhookUserInput')).not.toBeInTheDocument();
expect(queryByTestId('webhookPasswordInput')).not.toBeInTheDocument();
expect(getByTestId('webhookViewHeadersSwitch')).toHaveAttribute('aria-checked', 'true');
await act(async () => {
userEvent.click(getByTestId('webhookViewHeadersSwitch'));
});
expect(getByTestId('webhookViewHeadersSwitch')).toHaveAttribute('aria-checked', 'false');
expect(queryByTestId('webhookHeadersKeyInput')).not.toBeInTheDocument();
expect(queryByTestId('webhookHeadersValueInput')).not.toBeInTheDocument();
});
describe('Step Validation', () => {
test('Steps work correctly when all fields valid', async () => {
const { queryByTestId, getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(getByTestId('horizontalStep1-current')).toBeInTheDocument();
expect(getByTestId('horizontalStep2-incomplete')).toBeInTheDocument();
expect(getByTestId('horizontalStep3-incomplete')).toBeInTheDocument();
expect(getByTestId('horizontalStep4-incomplete')).toBeInTheDocument();
expect(getByTestId('authStep')).toHaveAttribute('style', 'display: block;');
expect(getByTestId('createStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('getStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('updateStep')).toHaveAttribute('style', 'display: none;');
expect(queryByTestId('casesWebhookBack')).not.toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
expect(getByTestId('horizontalStep1-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep2-current')).toBeInTheDocument();
expect(getByTestId('horizontalStep3-incomplete')).toBeInTheDocument();
expect(getByTestId('horizontalStep4-incomplete')).toBeInTheDocument();
expect(getByTestId('authStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('createStep')).toHaveAttribute('style', 'display: block;');
expect(getByTestId('getStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('updateStep')).toHaveAttribute('style', 'display: none;');
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
expect(getByTestId('horizontalStep1-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep2-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep3-current')).toBeInTheDocument();
expect(getByTestId('horizontalStep4-incomplete')).toBeInTheDocument();
expect(getByTestId('authStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('createStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('getStep')).toHaveAttribute('style', 'display: block;');
expect(getByTestId('updateStep')).toHaveAttribute('style', 'display: none;');
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
expect(getByTestId('horizontalStep1-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep2-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep3-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep4-current')).toBeInTheDocument();
expect(getByTestId('authStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('createStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('getStep')).toHaveAttribute('style', 'display: none;');
expect(getByTestId('updateStep')).toHaveAttribute('style', 'display: block;');
expect(queryByTestId('casesWebhookNext')).not.toBeInTheDocument();
});
test('Step 1 is properly validated', async () => {
const incompleteActionConnector = {
...actionConnector,
secrets: {
user: '',
password: '',
},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={incompleteActionConnector}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(getByTestId('horizontalStep1-current')).toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
await waitForComponentToUpdate();
expect(getByTestId('horizontalStep1-danger')).toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('hasAuthToggle'));
userEvent.click(getByTestId('webhookViewHeadersSwitch'));
});
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
expect(getByTestId('horizontalStep1-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep2-current')).toBeInTheDocument();
});
test('Step 2 is properly validated', async () => {
const incompleteActionConnector = {
...actionConnector,
config: {
...actionConnector.config,
createIncidentUrl: undefined,
},
};
const { getByText, getByTestId } = render(
<ConnectorFormTestProvider connector={incompleteActionConnector}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(getByTestId('horizontalStep2-incomplete')).toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
getByText(i18n.CREATE_URL_REQUIRED);
expect(getByTestId('horizontalStep2-danger')).toBeInTheDocument();
await act(async () => {
await userEvent.type(
getByTestId('webhookCreateUrlText'),
`{selectall}{backspace}${config.createIncidentUrl}`,
{
delay: 10,
}
);
});
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
expect(getByTestId('horizontalStep2-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep3-current')).toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('horizontalStep2-complete'));
});
expect(getByTestId('horizontalStep2-current')).toBeInTheDocument();
expect(getByTestId('horizontalStep3-incomplete')).toBeInTheDocument();
});
test('Step 3 is properly validated', async () => {
const incompleteActionConnector = {
...actionConnector,
config: {
...actionConnector.config,
getIncidentResponseExternalTitleKey: undefined,
},
};
const { getByText, getByTestId } = render(
<ConnectorFormTestProvider connector={incompleteActionConnector}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(getByTestId('horizontalStep2-incomplete')).toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
getByText(i18n.GET_RESPONSE_EXTERNAL_TITLE_KEY_REQUIRED);
expect(getByTestId('horizontalStep3-danger')).toBeInTheDocument();
await act(async () => {
await userEvent.type(
getByTestId('getIncidentResponseExternalTitleKeyText'),
`{selectall}{backspace}${config.getIncidentResponseExternalTitleKey}`,
{
delay: 10,
}
);
});
await act(async () => {
userEvent.click(getByTestId('casesWebhookNext'));
});
expect(getByTestId('horizontalStep3-complete')).toBeInTheDocument();
expect(getByTestId('horizontalStep4-current')).toBeInTheDocument();
await act(async () => {
userEvent.click(getByTestId('horizontalStep3-complete'));
});
expect(getByTestId('horizontalStep3-current')).toBeInTheDocument();
expect(getByTestId('horizontalStep4-incomplete')).toBeInTheDocument();
});
// step 4 is not validated like the others since it is the last step
// this validation is tested in the main validation section
});
describe('Validation', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const tests: Array<[string, string]> = [
['webhookCreateUrlText', 'not-valid'],
['webhookUserInput', ''],
['webhookPasswordInput', ''],
['incidentViewUrlInput', 'https://missingexternalid.com'],
['createIncidentResponseKeyText', ''],
['getIncidentUrlInput', 'https://missingexternalid.com'],
['getIncidentResponseExternalTitleKeyText', ''],
['getIncidentResponseCreatedDateKeyText', ''],
['getIncidentResponseUpdatedDateKeyText', ''],
['updateIncidentUrlInput', 'badurl.com'],
['createCommentUrlInput', 'badurl.com'],
];
const mustacheTests: Array<[string, string, string[]]> = [
['createIncidentJson', invalidJsonTitle, ['{{{case.title}}}']],
['createIncidentJson', invalidJsonBoth, ['{{{case.title}}}', '{{{case.description}}}']],
['updateIncidentJson', invalidJsonTitle, ['{{{case.title}}}']],
['updateIncidentJson', invalidJsonBoth, ['{{{case.title}}}', '{{{case.description}}}']],
['createCommentJson', invalidJsonBoth, ['{{{case.comment}}}']],
[
'incidentViewUrl',
'https://missingexternalid.com',
['{{{external.system.id}}}', '{{{external.system.title}}}'],
],
['getIncidentUrl', 'https://missingexternalid.com', ['{{{external.system.id}}}']],
];
it('connector validation succeeds when connector config is valid', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
const { isPreconfigured, ...rest } = actionConnector;
expect(onSubmit).toBeCalledWith({
data: {
...rest,
__internal__: {
hasHeaders: true,
},
},
isValid: true,
});
});
it('connector validation succeeds when auth=false', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
hasAuth: false,
},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
const { isPreconfigured, secrets, ...rest } = actionConnector;
expect(onSubmit).toBeCalledWith({
data: {
...rest,
config: {
...actionConnector.config,
hasAuth: false,
},
__internal__: {
hasHeaders: true,
},
},
isValid: true,
});
});
it('connector validation succeeds without headers', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
headers: null,
},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
const { isPreconfigured, ...rest } = actionConnector;
const { headers, ...rest2 } = actionConnector.config;
expect(onSubmit).toBeCalledWith({
data: {
...rest,
config: rest2,
__internal__: {
hasHeaders: false,
},
},
isValid: true,
});
});
it('validates correctly if the method is empty', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
createIncidentMethod: '',
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
it.each(tests)('validates correctly %p', async (field, value) => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
headers: [],
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
await userEvent.type(res.getByTestId(field), `{selectall}{backspace}${value}`, {
delay: 10,
});
});
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
it.each(mustacheTests)(
'validates mustache field correctly %p',
async (field, value, missingVariables) => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
[field]: value,
headers: [],
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<CasesWebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
expect(res.getByText(i18n.MISSING_VARIABLES(missingVariables))).toBeInTheDocument();
}
);
});
});

View file

@ -0,0 +1,210 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiSpacer,
EuiStepsHorizontal,
EuiStepStatus,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionConnectorFieldsProps } from '../../../../types';
import * as i18n from './translations';
import { AuthStep, CreateStep, GetStep, UpdateStep } from './steps';
export const HTTP_VERBS = ['post', 'put', 'patch'];
const fields = {
step1: [
'config.hasAuth',
'secrets.user',
'secrets.password',
'__internal__.hasHeaders',
'config.headers',
],
step2: [
'config.createIncidentMethod',
'config.createIncidentUrl',
'config.createIncidentJson',
'config.createIncidentResponseKey',
],
step3: [
'config.getIncidentUrl',
'config.getIncidentResponseExternalTitleKey',
'config.getIncidentResponseCreatedDateKey',
'config.getIncidentResponseUpdatedDateKey',
'config.incidentViewUrl',
],
step4: [
'config.updateIncidentMethod',
'config.updateIncidentUrl',
'config.updateIncidentJson',
'config.createCommentMethod',
'config.createCommentUrl',
'config.createCommentJson',
],
};
type PossibleStepNumbers = 1 | 2 | 3 | 4;
const CasesWebhookActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
}) => {
const { docLinks } = useKibana().services;
const { isValid, getFields, validateFields } = useFormContext();
const [currentStep, setCurrentStep] = useState<PossibleStepNumbers>(1);
const [status, setStatus] = useState<Record<string, EuiStepStatus>>({
step1: 'incomplete',
step2: 'incomplete',
step3: 'incomplete',
step4: 'incomplete',
});
const updateStatus = useCallback(async () => {
const steps: PossibleStepNumbers[] = [1, 2, 3, 4];
const currentFields = getFields();
const statuses = steps.map((index) => {
if (typeof isValid !== 'undefined' && !isValid) {
const fieldsToValidate = fields[`step${index}`];
// submit validation fields have already been through validator
// so we can look at the isValid property from `getFields()`
const areFieldsValid = fieldsToValidate.every((field) =>
currentFields[field] !== undefined ? currentFields[field].isValid : true
);
return {
[`step${index}`]: areFieldsValid ? 'complete' : ('danger' as EuiStepStatus),
};
}
return {
[`step${index}`]:
currentStep === index
? 'current'
: currentStep > index
? 'complete'
: ('incomplete' as EuiStepStatus),
};
});
setStatus(statuses.reduce((acc: Record<string, EuiStepStatus>, i) => ({ ...acc, ...i }), {}));
}, [currentStep, getFields, isValid]);
useEffect(() => {
updateStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isValid, currentStep]);
const onNextStep = useCallback(
async (selectedStep?: PossibleStepNumbers) => {
const nextStep =
selectedStep != null
? selectedStep
: currentStep === 4
? currentStep
: ((currentStep + 1) as PossibleStepNumbers);
const fieldsToValidate: string[] =
nextStep === 2
? fields.step1
: nextStep === 3
? [...fields.step1, ...fields.step2]
: nextStep === 4
? [...fields.step1, ...fields.step2, ...fields.step3]
: [];
// step validation needs async call in order to run each field through validator
const { areFieldsValid } = await validateFields(fieldsToValidate);
if (!areFieldsValid) {
setStatus((currentStatus) => ({
...currentStatus,
[`step${currentStep}`]: 'danger',
}));
return;
}
if (nextStep < 5) {
setCurrentStep(nextStep);
}
},
[currentStep, validateFields]
);
const horizontalSteps = useMemo(
() => [
{
title: i18n.STEP_1,
status: status.step1,
onClick: () => setCurrentStep(1),
['data-test-subj']: `horizontalStep1-${status.step1}`,
},
{
title: i18n.STEP_2,
status: status.step2,
onClick: () => onNextStep(2),
['data-test-subj']: `horizontalStep2-${status.step2}`,
},
{
title: i18n.STEP_3,
status: status.step3,
onClick: () => onNextStep(3),
['data-test-subj']: `horizontalStep3-${status.step3}`,
},
{
title: i18n.STEP_4,
status: status.step4,
onClick: () => onNextStep(4),
['data-test-subj']: `horizontalStep4-${status.step4}`,
},
],
[onNextStep, status]
);
return (
<>
<EuiStepsHorizontal steps={horizontalSteps} />
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/cases-webhook-action-type.html`}
target="_blank"
>
{i18n.DOC_LINK}
</EuiLink>
<EuiSpacer size="l" />
<AuthStep readOnly={readOnly} display={currentStep === 1} />
<CreateStep readOnly={readOnly} display={currentStep === 2} />
<GetStep readOnly={readOnly} display={currentStep === 3} />
<UpdateStep readOnly={readOnly} display={currentStep === 4} />
<EuiFlexGroup alignItems="flexStart" justifyContent="flexStart" direction="rowReverse">
{currentStep < 4 && (
<EuiFlexItem grow={false} style={{ minWidth: 160 }}>
<EuiButton
data-test-subj="casesWebhookNext"
fill
iconSide="right"
iconType="arrowRight"
onClick={() => onNextStep()}
>
{i18n.NEXT}
</EuiButton>
</EuiFlexItem>
)}
{currentStep > 1 && (
<EuiFlexItem grow={false} style={{ minWidth: 160 }}>
<EuiButton
data-test-subj="casesWebhookBack"
iconSide="left"
iconType="arrowLeft"
onClick={() => onNextStep((currentStep - 1) as PossibleStepNumbers)}
>
{i18n.PREVIOUS}
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { CasesWebhookActionConnectorFields as default };

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import WebhookParamsFields from './webhook_params';
import { MockCodeEditor } from '../../../code_editor.mock';
import { CasesWebhookActionConnector } from './types';
const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public';
jest.mock(kibanaReactPath, () => {
const original = jest.requireActual(kibanaReactPath);
return {
...original,
CodeEditor: (props: any) => {
return <MockCodeEditor {...props} />;
},
};
});
const actionParams = {
subAction: 'pushToService',
subActionParams: {
incident: {
title: 'sn title',
description: 'some description',
tags: ['kibana'],
externalId: null,
},
comments: [],
},
};
const actionConnector = {
config: {
createCommentUrl: 'https://elastic.co',
createCommentJson: {},
},
} as unknown as CasesWebhookActionConnector;
describe('WebhookParamsFields renders', () => {
test('all params fields is rendered', () => {
const wrapper = mountWithIntl(
<WebhookParamsFields
actionConnector={actionConnector}
actionParams={actionParams}
errors={{ body: [] }}
editAction={() => {}}
index={0}
messageVariables={[
{
name: 'myVar',
description: 'My variable description',
useWithTripleBracesInTemplates: true,
},
]}
/>
);
expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="tagsComboBox"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').first().prop('disabled')).toEqual(
false
);
});
test('comments field is disabled when comment data is missing', () => {
const actionConnectorNoComments = {
config: {},
} as unknown as CasesWebhookActionConnector;
const wrapper = mountWithIntl(
<WebhookParamsFields
actionConnector={actionConnectorNoComments}
actionParams={actionParams}
errors={{ body: [] }}
editAction={() => {}}
index={0}
messageVariables={[
{
name: 'myVar',
description: 'My variable description',
useWithTripleBracesInTemplates: true,
},
]}
/>
);
expect(wrapper.find('[data-test-subj="commentsTextArea"]').first().prop('disabled')).toEqual(
true
);
});
});

View file

@ -0,0 +1,210 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut, EuiComboBox, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { ActionParamsProps } from '../../../../types';
import { CasesWebhookActionConnector, CasesWebhookActionParams } from './types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
const CREATE_COMMENT_WARNING_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.textAreaWithMessageVariable.createCommentWarningTitle',
{
defaultMessage: 'Unable to share case comments',
}
);
const CREATE_COMMENT_WARNING_DESC = i18n.translate(
'xpack.triggersActionsUI.components.textAreaWithMessageVariable.createCommentWarningDesc',
{
defaultMessage:
'Configure the Create Comment URL and Create Comment Objects fields for the connector to share comments externally.',
}
);
const WebhookParamsFields: React.FunctionComponent<ActionParamsProps<CasesWebhookActionParams>> = ({
actionConnector,
actionParams,
editAction,
errors,
index,
messageVariables,
}) => {
const { incident, comments } = useMemo(
() =>
actionParams.subActionParams ??
({
incident: {},
comments: [],
} as unknown as CasesWebhookActionParams['subActionParams']),
[actionParams.subActionParams]
);
const { createCommentUrl, createCommentJson } = (
actionConnector as unknown as CasesWebhookActionConnector
).config;
const labelOptions = useMemo(
() => (incident.tags ? incident.tags.map((label: string) => ({ label })) : []),
[incident.tags]
);
const editSubActionProperty = useCallback(
(key: string, value: any) => {
return editAction(
'subActionParams',
{
incident: { ...incident, [key]: value },
comments,
},
index
);
},
[comments, editAction, incident, index]
);
const editComment = useCallback(
(key, value) => {
return editAction(
'subActionParams',
{
incident,
comments: [{ commentId: '1', comment: value }],
},
index
);
},
[editAction, incident, index]
);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!actionParams.subActionParams) {
editAction(
'subActionParams',
{
incident: {},
comments: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionParams]);
return (
<>
<EuiFormRow
data-test-subj="title-row"
fullWidth
error={errors['subActionParams.incident.title']}
isInvalid={
errors['subActionParams.incident.title'] !== undefined &&
errors['subActionParams.incident.title'].length > 0 &&
incident.title !== undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhook.titleFieldLabel',
{
defaultMessage: 'Summary (required)',
}
)}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'title'}
inputTargetValue={incident.title ?? undefined}
errors={errors['subActionParams.incident.title'] as string[]}
/>
</EuiFormRow>
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={incident.description ?? undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhook.descriptionTextAreaFieldLabel',
{
defaultMessage: 'Description',
}
)}
/>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhook.tagsFieldLabel',
{
defaultMessage: 'Tags',
}
)}
error={errors['subActionParams.incident.tags'] as string[]}
>
<EuiComboBox
noSuggestions
fullWidth
selectedOptions={labelOptions}
onCreateOption={(searchValue: string) => {
const newOptions = [...labelOptions, { label: searchValue }];
editSubActionProperty(
'tags',
newOptions.map((newOption) => newOption.label)
);
}}
onChange={(selectedOptions: Array<{ label: string }>) => {
editSubActionProperty(
'tags',
selectedOptions.map((selectedOption) => selectedOption.label)
);
}}
onBlur={() => {
if (!incident.tags) {
editSubActionProperty('tags', []);
}
}}
isClearable={true}
data-test-subj="tagsComboBox"
/>
</EuiFormRow>
<>
<TextAreaWithMessageVariables
index={index}
isDisabled={!createCommentUrl || !createCommentJson}
editAction={editComment}
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhook.commentsTextAreaFieldLabel',
{
defaultMessage: 'Additional comments',
}
)}
/>
{(!createCommentUrl || !createCommentJson) && (
<>
<EuiSpacer size="m" />
<EuiCallOut
title={CREATE_COMMENT_WARNING_TITLE}
color="warning"
iconType="help"
size="s"
>
<p>{CREATE_COMMENT_WARNING_DESC}</p>
</EuiCallOut>
</>
)}
</>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { WebhookParamsFields as default };

View file

@ -12,6 +12,7 @@ import { getEmailActionType } from './email';
import { getIndexActionType } from './es_index';
import { getPagerDutyActionType } from './pagerduty';
import { getSwimlaneActionType } from './swimlane';
import { getCasesWebhookActionType } from './cases_webhook';
import { getWebhookActionType } from './webhook';
import { getXmattersActionType } from './xmatters';
import { TypeRegistry } from '../../type_registry';
@ -45,6 +46,7 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getIndexActionType());
actionTypeRegistry.register(getPagerDutyActionType());
actionTypeRegistry.register(getSwimlaneActionType());
actionTypeRegistry.register(getCasesWebhookActionType());
actionTypeRegistry.register(getWebhookActionType());
actionTypeRegistry.register(getXmattersActionType());
actionTypeRegistry.register(getServiceNowITSMActionType());

View file

@ -34,6 +34,7 @@ const NO_EDITOR_ERROR_MESSAGE = i18n.translate(
);
interface Props {
buttonTitle?: string;
messageVariables?: ActionVariable[];
paramsProperty: string;
inputTargetValue?: string;
@ -43,6 +44,8 @@ interface Props {
onDocumentsChange: (data: string) => void;
helpText?: JSX.Element;
onBlur?: () => void;
showButtonTitle?: boolean;
euiCodeEditorProps?: { [key: string]: any };
}
const { useXJsonMode } = XJson;
@ -53,6 +56,7 @@ const { useXJsonMode } = XJson;
const EDITOR_SOURCE = 'json-editor-with-message-variables';
export const JsonEditorWithMessageVariables: React.FunctionComponent<Props> = ({
buttonTitle,
messageVariables,
paramsProperty,
inputTargetValue,
@ -62,6 +66,8 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent<Props> = ({
onDocumentsChange,
helpText,
onBlur,
showButtonTitle,
euiCodeEditorProps = {},
}) => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
const editorDisposables = useRef<monaco.IDisposable[]>([]);
@ -148,9 +154,11 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent<Props> = ({
label={label}
labelAppend={
<AddMessageVariables
buttonTitle={buttonTitle}
messageVariables={messageVariables}
onSelectEventHandler={onSelectMessageVariable}
paramsProperty={paramsProperty}
showButtonTitle={showButtonTitle}
/>
}
helpText={helpText}
@ -177,6 +185,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent<Props> = ({
height="200px"
data-test-subj={`${paramsProperty}JsonEditor`}
aria-label={areaLabel}
{...euiCodeEditorProps}
editorDidMount={onEditorMount}
onChange={(xjson: string) => {
setXJson(xjson);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { css } from '@emotion/react';
export const styles = {
editor: css`
.euiFormRow__fieldWrapper .kibanaCodeEditor {
height: auto;
}
`,
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import {
FieldHook,
getFieldValidityAndErrorMessage,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import React, { useCallback } from 'react';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { styles } from './json_field_wrapper.styles';
import { JsonEditorWithMessageVariables } from './json_editor_with_message_variables';
interface Props {
field: FieldHook<any, string>;
messageVariables?: ActionVariable[];
paramsProperty: string;
euiCodeEditorProps?: { [key: string]: any };
[key: string]: any;
}
export const JsonFieldWrapper = ({ field, ...rest }: Props) => {
const { errorMessage } = getFieldValidityAndErrorMessage(field);
const { label, helpText, value, setValue } = field;
const onJsonUpdate = useCallback(
(updatedJson: string) => {
setValue(updatedJson);
},
[setValue]
);
return (
<span css={styles.editor}>
<JsonEditorWithMessageVariables
errors={errorMessage ? [errorMessage] : []}
helpText={<p>{helpText}</p>}
inputTargetValue={value}
label={
label ??
i18n.translate('xpack.triggersActionsUI.jsonFieldWrapper.defaultLabel', {
defaultMessage: 'JSON Editor',
})
}
onDocumentsChange={onJsonUpdate}
{...rest}
/>
</span>
);
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
FieldHook,
getFieldValidityAndErrorMessage,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import React, { useCallback } from 'react';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { TextFieldWithMessageVariables } from './text_field_with_message_variables';
interface Props {
field: FieldHook<any, string>;
messageVariables?: ActionVariable[];
paramsProperty: string;
euiFieldProps: { [key: string]: any; paramsProperty: string };
[key: string]: any;
}
export const MustacheTextFieldWrapper = ({ field, euiFieldProps, idAria, ...rest }: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const { value, setValue } = field;
const editAction = useCallback(
(property: string, newValue: string) => {
setValue(newValue);
},
[setValue]
);
return (
<TextFieldWithMessageVariables
errors={errorMessage ? [errorMessage] : []}
formRowProps={{
describedByIds: idAria ? [idAria] : undefined,
error: errorMessage,
fullWidth: true,
helpText: typeof field.helpText === 'function' ? field.helpText() : field.helpText,
isInvalid,
label: field.label,
...rest,
}}
index={0}
inputTargetValue={value}
wrapField
{...euiFieldProps}
editAction={editAction}
/>
);
};

View file

@ -17,6 +17,7 @@ interface Props {
paramsProperty: string;
index: number;
inputTargetValue?: string;
isDisabled?: boolean;
editAction: (property: string, value: any, index: number) => void;
label: string;
errors?: string[];
@ -27,6 +28,7 @@ export const TextAreaWithMessageVariables: React.FunctionComponent<Props> = ({
paramsProperty,
index,
inputTargetValue,
isDisabled = false,
editAction,
label,
errors,
@ -52,6 +54,7 @@ export const TextAreaWithMessageVariables: React.FunctionComponent<Props> = ({
<EuiFormRow
fullWidth
error={errors}
isDisabled={isDisabled}
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
label={label}
labelAppend={
@ -63,6 +66,7 @@ export const TextAreaWithMessageVariables: React.FunctionComponent<Props> = ({
}
>
<EuiTextArea
disabled={isDisabled}
fullWidth
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
name={paramsProperty}

View file

@ -5,14 +5,15 @@
* 2.0.
*/
import React, { useState } from 'react';
import { EuiFieldText } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import './add_message_variables.scss';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { AddMessageVariables } from './add_message_variables';
import { templateActionVariable } from '../lib';
interface Props {
buttonTitle?: string;
messageVariables?: ActionVariable[];
paramsProperty: string;
index: number;
@ -20,59 +21,110 @@ interface Props {
editAction: (property: string, value: any, index: number) => void;
errors?: string[];
defaultValue?: string | number | string[];
wrapField?: boolean;
formRowProps?: {
describedByIds?: string[];
error: string | null;
fullWidth: boolean;
helpText: string;
isInvalid: boolean;
label?: string;
};
showButtonTitle?: boolean;
}
const Wrapper = ({
children,
wrapField,
formRowProps,
button,
}: {
wrapField: boolean;
children: React.ReactElement;
button: React.ReactElement;
formRowProps?: {
describedByIds?: string[];
error: string | null;
fullWidth: boolean;
helpText: string;
isInvalid: boolean;
label?: string;
};
}) =>
wrapField ? (
<EuiFormRow {...formRowProps} labelAppend={button}>
{children}
</EuiFormRow>
) : (
<>{children}</>
);
export const TextFieldWithMessageVariables: React.FunctionComponent<Props> = ({
buttonTitle,
messageVariables,
paramsProperty,
index,
inputTargetValue,
editAction,
errors,
formRowProps,
defaultValue,
wrapField = false,
showButtonTitle,
}) => {
const [currentTextElement, setCurrentTextElement] = useState<HTMLInputElement | null>(null);
const onSelectMessageVariable = (variable: ActionVariable) => {
const templatedVar = templateActionVariable(variable);
const startPosition = currentTextElement?.selectionStart ?? 0;
const endPosition = currentTextElement?.selectionEnd ?? 0;
const newValue =
(inputTargetValue ?? '').substring(0, startPosition) +
templatedVar +
(inputTargetValue ?? '').substring(endPosition, (inputTargetValue ?? '').length);
editAction(paramsProperty, newValue, index);
};
const onSelectMessageVariable = useCallback(
(variable: ActionVariable) => {
const templatedVar = templateActionVariable(variable);
const startPosition = currentTextElement?.selectionStart ?? 0;
const endPosition = currentTextElement?.selectionEnd ?? 0;
const newValue =
(inputTargetValue ?? '').substring(0, startPosition) +
templatedVar +
(inputTargetValue ?? '').substring(endPosition, (inputTargetValue ?? '').length);
editAction(paramsProperty, newValue, index);
},
[currentTextElement, editAction, index, inputTargetValue, paramsProperty]
);
const onChangeWithMessageVariable = (e: React.ChangeEvent<HTMLInputElement>) => {
editAction(paramsProperty, e.target.value, index);
};
const VariableButton = useMemo(
() => (
<AddMessageVariables
buttonTitle={buttonTitle}
messageVariables={messageVariables}
onSelectEventHandler={onSelectMessageVariable}
paramsProperty={paramsProperty}
showButtonTitle={showButtonTitle}
/>
),
[buttonTitle, messageVariables, onSelectMessageVariable, paramsProperty, showButtonTitle]
);
return (
<EuiFieldText
fullWidth
name={paramsProperty}
id={`${paramsProperty}Id`}
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
data-test-subj={`${paramsProperty}Input`}
value={inputTargetValue || ''}
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChangeWithMessageVariable(e)}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
setCurrentTextElement(e.target);
}}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
if (!inputTargetValue) {
editAction(paramsProperty, '', index);
}
}}
append={
<AddMessageVariables
messageVariables={messageVariables}
onSelectEventHandler={onSelectMessageVariable}
paramsProperty={paramsProperty}
/>
}
/>
<Wrapper wrapField={wrapField} formRowProps={formRowProps} button={VariableButton}>
<EuiFieldText
fullWidth
name={paramsProperty}
id={`${paramsProperty}Id`}
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
data-test-subj={`${paramsProperty}Input`}
value={inputTargetValue || ''}
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChangeWithMessageVariable(e)}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
setCurrentTextElement(e.target);
}}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
if (!inputTargetValue) {
editAction(paramsProperty, '', index);
}
}}
append={wrapField ? undefined : VariableButton}
/>
</Wrapper>
);
};

View file

@ -16,6 +16,7 @@ import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { useKibana } from '../../../common/lib/kibana';
import { SectionLoading } from '../../components/section_loading';
import { betaBadgeProps } from './beta_badge_props';
interface Props {
onActionTypeChange: (actionType: ActionType) => void;
@ -82,6 +83,7 @@ export const ActionTypeMenu = ({
selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '',
actionType,
name: actionType.name,
isExperimental: actionTypeModel.isExperimental,
};
});
@ -91,6 +93,7 @@ export const ActionTypeMenu = ({
const checkEnabledResult = checkActionTypeEnabled(item.actionType);
const card = (
<EuiCard
betaBadgeProps={item.isExperimental ? betaBadgeProps : undefined}
titleSize="xs"
data-test-subj={`${item.actionType.id}-card`}
icon={<EuiIcon size="xl" type={item.iconClass} />}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const betaBadgeProps = {
label: i18n.translate('xpack.triggersActionsUI.technicalPreviewBadgeLabel', {
defaultMessage: 'Technical preview',
}),
tooltipContent: i18n.translate('xpack.triggersActionsUI.technicalPreviewBadgeDescription', {
defaultMessage:
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
}),
};

View file

@ -50,7 +50,7 @@ interface Props {
// TODO: Remove when https://github.com/elastic/kibana/issues/133107 is resolved
const formDeserializer = (data: ConnectorFormSchema): ConnectorFormSchema => {
if (data.actionTypeId !== '.webhook') {
if (data.actionTypeId !== '.webhook' && data.actionTypeId !== '.cases-webhook') {
return data;
}
@ -71,7 +71,7 @@ const formDeserializer = (data: ConnectorFormSchema): ConnectorFormSchema => {
// TODO: Remove when https://github.com/elastic/kibana/issues/133107 is resolved
const formSerializer = (formData: ConnectorFormSchema): ConnectorFormSchema => {
if (formData.actionTypeId !== '.webhook') {
if (formData.actionTypeId !== '.webhook' && formData.actionTypeId !== '.cases-webhook') {
return formData;
}

View file

@ -16,16 +16,27 @@ import {
EuiFlyoutHeader,
IconType,
EuiSpacer,
EuiBetaBadge,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { getConnectorFeatureName } from '@kbn/actions-plugin/common';
import { betaBadgeProps } from '../beta_badge_props';
const FlyoutHeaderComponent: React.FC<{
interface Props {
icon?: IconType | null;
actionTypeName?: string | null;
actionTypeMessage?: string | null;
featureIds?: string[] | null;
}> = ({ icon, actionTypeName, actionTypeMessage, featureIds }) => {
isExperimental?: boolean;
}
const FlyoutHeaderComponent: React.FC<Props> = ({
icon,
actionTypeName,
actionTypeMessage,
featureIds,
isExperimental,
}) => {
return (
<EuiFlyoutHeader hasBorder data-test-subj="create-connector-flyout-header">
<EuiFlexGroup gutterSize="m" alignItems="center">
@ -85,9 +96,17 @@ const FlyoutHeaderComponent: React.FC<{
</EuiTitle>
)}
</EuiFlexItem>
{actionTypeName && isExperimental && (
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={betaBadgeProps.label}
tooltipContent={betaBadgeProps.tooltipContent}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutHeader>
);
};
export const FlyoutHeader = memo(FlyoutHeaderComponent);
export const FlyoutHeader: React.NamedExoticComponent<Props> = memo(FlyoutHeaderComponent);

View file

@ -157,6 +157,7 @@ const CreateConnectorFlyoutComponent: React.FC<CreateConnectorFlyoutProps> = ({
actionTypeName={actionType?.name}
actionTypeMessage={actionTypeModel?.selectMessage}
featureIds={actionType?.supportedFeatureIds}
isExperimental={actionTypeModel?.isExperimental}
/>
<EuiFlyoutBody
banner={!actionType && hasActionsUpgradeableByTrial ? <UpgradeLicenseCallOut /> : null}

View file

@ -198,6 +198,7 @@ export interface ActionTypeModel<ActionConfig = any, ActionSecrets = any, Action
> | null;
actionParamsFields: React.LazyExoticComponent<ComponentType<ActionParamsProps<ActionParams>>>;
customConnectorSelectItem?: CustomConnectorSelectionItem;
isExperimental?: boolean;
}
export interface GenericValidationResult<T> {

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
ExternalServiceSimulator,
getExternalServiceSimulatorPath,
} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin';
// eslint-disable-next-line import/no-default-export
export default function casesWebhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const config = {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: 'post',
createCommentUrl:
'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"labels":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: 'post',
createIncidentResponseKey: 'id',
createIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: { ['content-type']: 'application/json' },
incidentViewUrl: 'https://siem-kibana.atlassian.net/browse/{{{external.system.title}}}',
getIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"labels":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: 'put',
updateIncidentUrl:
'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
};
const mockCasesWebhook = {
config,
secrets: {
user: 'user',
password: 'pass',
},
params: {
incident: {
summary: 'a title',
description: 'a description',
labels: ['kibana'],
},
comments: [
{
commentId: '456',
comment: 'first comment',
},
],
},
};
describe('casesWebhook', () => {
let casesWebhookSimulatorURL: string = '<could not determine kibana url>';
before(() => {
// use jira because cases webhook works with any third party case management system
casesWebhookSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.JIRA)
);
});
it('should return 403 when creating a cases webhook action', async () => {
await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'A cases webhook action',
actionTypeId: '.cases-webhook',
config: {
...config,
createCommentUrl: `${casesWebhookSimulatorURL}/{{{external.system.id}}}/comments`,
createIncidentUrl: casesWebhookSimulatorURL,
incidentViewUrl: `${casesWebhookSimulatorURL}/{{{external.system.title}}}`,
getIncidentUrl: `${casesWebhookSimulatorURL}/{{{external.system.id}}}`,
updateIncidentUrl: `${casesWebhookSimulatorURL}/{{{external.system.id}}}`,
},
secrets: mockCasesWebhook.secrets,
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .cases-webhook is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function actionsTests({ loadTestFile }: FtrProviderContext) {
describe('Actions', () => {
loadTestFile(require.resolve('./builtin_action_types/cases_webhook'));
loadTestFile(require.resolve('./builtin_action_types/email'));
loadTestFile(require.resolve('./builtin_action_types/es_index'));
loadTestFile(require.resolve('./builtin_action_types/jira'));

View file

@ -30,6 +30,7 @@ interface CreateTestConfigOptions {
// test.not-enabled is specifically not enabled
const enabledActionTypes = [
'.cases-webhook',
'.email',
'.index',
'.pagerduty',

View file

@ -0,0 +1,741 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import httpProxy from 'http-proxy';
import expect from '@kbn/expect';
import { getHttpProxyServer } from '../../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../../common/fixtures/plugins/actions_simulators/server/plugin';
// eslint-disable-next-line import/no-default-export
export default function casesWebhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const configService = getService('config');
const config = {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: 'post',
createCommentUrl:
'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"labels":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: 'post',
createIncidentResponseKey: 'id',
createIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: { ['content-type']: 'application/json', ['kbn-xsrf']: 'abcd' },
incidentViewUrl: 'https://siem-kibana.atlassian.net/browse/{{{external.system.title}}}',
getIncidentUrl: 'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"labels":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: 'put',
updateIncidentUrl:
'https://siem-kibana.atlassian.net/rest/api/2/issue/{{{external.system.id}}}',
};
const requiredFields = [
'createIncidentJson',
'createIncidentResponseKey',
'createIncidentUrl',
'getIncidentResponseCreatedDateKey',
'getIncidentResponseExternalTitleKey',
'getIncidentResponseUpdatedDateKey',
'incidentViewUrl',
'getIncidentUrl',
'updateIncidentJson',
'updateIncidentUrl',
];
const secrets = {
user: 'user',
password: 'pass',
};
const mockCasesWebhook = {
config,
secrets,
params: {
subAction: 'pushToService',
subActionParams: {
incident: {
title: 'a title',
description: 'a description',
externalId: null,
},
comments: [
{
comment: 'first comment',
commentId: '456',
},
],
},
},
};
let casesWebhookSimulatorURL: string = '<could not determine kibana url>';
let simulatorConfig: Record<string, string | boolean | Record<string, string>>;
describe('CasesWebhook', () => {
before(() => {
// use jira because cases webhook works with any third party case management system
casesWebhookSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.JIRA)
);
simulatorConfig = {
...mockCasesWebhook.config,
createCommentUrl: `${casesWebhookSimulatorURL}/rest/api/2/issue/{{{external.system.id}}}/comment`,
createIncidentUrl: `${casesWebhookSimulatorURL}/rest/api/2/issue`,
incidentViewUrl: `${casesWebhookSimulatorURL}/browse/{{{external.system.title}}}`,
getIncidentUrl: `${casesWebhookSimulatorURL}/rest/api/2/issue/{{{external.system.id}}}`,
updateIncidentUrl: `${casesWebhookSimulatorURL}/rest/api/2/issue/{{{external.system.id}}}`,
};
});
describe('CasesWebhook - Action Creation', () => {
it('should return 200 when creating a casesWebhook action successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook action',
connector_type_id: '.cases-webhook',
config: simulatorConfig,
secrets,
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_deprecated: false,
name: 'A casesWebhook action',
connector_type_id: '.cases-webhook',
is_missing_secrets: false,
config: simulatorConfig,
});
const { body: fetchedAction } = await supertest
.get(`/api/actions/connector/${createdAction.id}`)
.expect(200);
expect(fetchedAction).to.eql({
id: fetchedAction.id,
is_preconfigured: false,
is_deprecated: false,
name: 'A casesWebhook action',
connector_type_id: '.cases-webhook',
is_missing_secrets: false,
config: simulatorConfig,
});
});
describe('400s for all required fields when missing', () => {
requiredFields.forEach((field) => {
it(`should respond with a 400 Bad Request when creating a casesWebhook action with no ${field}`, async () => {
const incompleteConfig = { ...simulatorConfig };
delete incompleteConfig[field];
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook action',
connector_type_id: '.cases-webhook',
config: incompleteConfig,
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: `error validating action type config: [${field}]: expected value of type [string] but got [undefined]`,
});
});
});
});
});
it('should respond with a 400 Bad Request when creating a casesWebhook action with a not present in allowedHosts apiUrl', async () => {
const badUrl = 'http://casesWebhook.mynonexistent.com';
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook action',
connector_type_id: '.cases-webhook',
config: {
...mockCasesWebhook.config,
createCommentUrl: `${badUrl}/{{{external.system.id}}}/comments`,
createIncidentUrl: badUrl,
incidentViewUrl: `${badUrl}/{{{external.system.title}}}`,
getIncidentUrl: `${badUrl}/{{{external.system.id}}}`,
updateIncidentUrl: `${badUrl}/{{{external.system.id}}}`,
},
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: error configuring cases webhook action: target url "http://casesWebhook.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
});
});
});
it('should respond with a 400 Bad Request when creating a casesWebhook action without secrets when hasAuth = true', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook action',
connector_type_id: '.cases-webhook',
config: simulatorConfig,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type connector: both user and password must be specified',
});
});
});
});
describe('CasesWebhook - Executor', () => {
let simulatedActionId: string;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook simulator',
connector_type_id: '.cases-webhook',
config: simulatorConfig,
secrets,
});
simulatedActionId = body.id;
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
describe('Validation', () => {
it('should handle failing with a simulated success without action', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
})
.then((resp: any) => {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
});
});
it('should handle failing with a simulated success without unsupported action', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'non-supported' },
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: [subAction]: expected value to equal [pushToService]',
});
});
});
it('should handle failing with a simulated success without subActionParams argument', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'pushToService' },
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: [subActionParams.incident.title]: expected value of type [string] but got [undefined]',
});
});
});
it('should handle failing with a simulated success without title', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
description: 'success',
},
comments: [],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: [subActionParams.incident.title]: expected value of type [string] but got [undefined]',
});
});
});
it('should handle failing with a simulated success without commentId', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
...mockCasesWebhook.params.subActionParams.incident,
description: 'success',
title: 'success',
},
comments: [{ comment: 'comment' }],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: [subActionParams.comments]: types that failed validation:\n- [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n- [subActionParams.comments.1]: expected value to equal [null]',
});
});
});
it('should handle failing with a simulated success without comment message', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
...mockCasesWebhook.params.subActionParams.incident,
title: 'success',
},
comments: [{ commentId: 'success' }],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: [subActionParams.comments]: types that failed validation:\n- [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n- [subActionParams.comments.1]: expected value to equal [null]',
});
});
});
});
describe('Execution', () => {
it('should handle creating an incident without comments', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: mockCasesWebhook.params.subActionParams.incident,
comments: [],
},
},
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(body).to.eql({
status: 'ok',
connector_id: simulatedActionId,
data: {
id: '123',
title: 'CK-1',
pushedDate: '2020-04-27T14:17:45.490Z',
url: `${casesWebhookSimulatorURL}/browse/CK-1`,
},
});
});
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
});
describe('CasesWebhook - Executor bad data', () => {
describe('bad case JSON', () => {
let simulatedActionId: string;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
const jsonExtraCommas =
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"labels":{{{case.tags}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}},,,,,';
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook simulator',
connector_type_id: '.cases-webhook',
config: {
...simulatorConfig,
createIncidentJson: jsonExtraCommas,
updateIncidentJson: jsonExtraCommas,
},
secrets,
});
simulatedActionId = body.id;
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
it('should respond with bad JSON error when create case JSON is bad', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
title: 'success',
description: 'success',
},
comments: [],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message: 'an error occurred while running the action',
service_message:
'[Action][Webhook - Case Management]: Unable to create case. Error: JSON Error: Create case JSON body must be valid JSON. ',
});
});
expect(proxyHaveBeenCalled).to.equal(false);
});
it('should respond with bad JSON error when update case JSON is bad', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
title: 'success',
description: 'success',
externalId: '12345',
},
comments: [],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message: 'an error occurred while running the action',
service_message:
'[Action][Webhook - Case Management]: Unable to update case with id 12345. Error: JSON Error: Update case JSON body must be valid JSON. ',
});
});
expect(proxyHaveBeenCalled).to.equal(false);
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
});
describe('bad comment JSON', () => {
let simulatedActionId: string;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook simulator',
connector_type_id: '.cases-webhook',
config: {
...simulatorConfig,
createCommentJson: '{"body":{{{case.comment}}}},,,,,,,',
},
secrets,
});
simulatedActionId = body.id;
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
it('should respond with bad JSON error when create case comment JSON is bad', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
title: 'success',
description: 'success',
},
comments: [
{
comment: 'first comment',
commentId: '456',
},
],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message: 'an error occurred while running the action',
service_message:
'[Action][Webhook - Case Management]: Unable to create comment at case with id 123. Error: JSON Error: Create comment JSON body must be valid JSON. ',
});
});
expect(proxyHaveBeenCalled).to.equal(true); // called for the create case successful call
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
});
});
describe('CasesWebhook - Executor bad URLs', () => {
describe('bad case URL', () => {
let simulatedActionId: string;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook simulator',
connector_type_id: '.cases-webhook',
config: {
...simulatorConfig,
createIncidentUrl: `https${casesWebhookSimulatorURL}`,
updateIncidentUrl: `${casesWebhookSimulatorURL}/rest/api/2/issue/{{{external.system.id}}}e\\\\whoathisisbad4{}\{\{`,
},
secrets,
});
simulatedActionId = body.id;
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
it('should respond with bad URL error when create case URL is bad', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
title: 'success',
description: 'success',
},
comments: [],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message: 'an error occurred while running the action',
service_message:
'[Action][Webhook - Case Management]: Unable to create case. Error: Invalid Create case URL: Error: Invalid protocol. ',
});
});
expect(proxyHaveBeenCalled).to.equal(false);
});
it('should respond with bad URL error when update case URL is bad', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
title: 'success',
description: 'success',
externalId: '12345',
},
comments: [],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message: 'an error occurred while running the action',
service_message:
'[Action][Webhook - Case Management]: Unable to update case with id 12345. Error: Invalid Update case URL: Error: Invalid URL. ',
});
});
expect(proxyHaveBeenCalled).to.equal(false);
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
});
describe('bad comment URL', () => {
let simulatedActionId: string;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A casesWebhook simulator',
connector_type_id: '.cases-webhook',
config: {
...simulatorConfig,
createCommentUrl: `${casesWebhookSimulatorURL}/rest/api/2/issue/{{{external.system.id}}}e\\\\whoathisisbad4{}\{\{`,
},
secrets,
});
simulatedActionId = body.id;
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
it('should respond with bad URL error when create case comment URL is bad', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockCasesWebhook.params,
subActionParams: {
incident: {
title: 'success',
description: 'success',
},
comments: [
{
comment: 'first comment',
commentId: '456',
},
],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message: 'an error occurred while running the action',
service_message:
'[Action][Webhook - Case Management]: Unable to create comment at case with id 123. Error: Invalid Create comment URL: Error: Invalid URL. ',
});
});
expect(proxyHaveBeenCalled).to.equal(true); // called for the create case successful call
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
});
});
});
}

View file

@ -18,7 +18,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo
after(async () => {
await tearDown(getService);
});
loadTestFile(require.resolve('./builtin_action_types/cases_webhook'));
loadTestFile(require.resolve('./builtin_action_types/email'));
loadTestFile(require.resolve('./builtin_action_types/es_index'));
loadTestFile(require.resolve('./builtin_action_types/es_index_preconfigured'));

View file

@ -21,6 +21,7 @@ interface CreateTestConfigOptions {
}
const enabledActionTypes = [
'.cases-webhook',
'.email',
'.index',
'.jira',

View file

@ -250,6 +250,36 @@ export const getJiraConnector = () => ({
},
});
export const getCasesWebhookConnector = () => ({
name: 'Cases Webhook Connector',
connector_type_id: '.cases-webhook',
secrets: {
user: 'user',
password: 'pass',
},
config: {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: 'post',
createCommentUrl: 'http://some.non.existent.com/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: 'post',
createIncidentResponseKey: 'id',
createIncidentUrl: 'http://some.non.existent.com/',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: { [`content-type`]: 'application/json' },
incidentViewUrl: 'http://some.non.existent.com/browse/{{{external.system.title}}}',
getIncidentUrl: 'http://some.non.existent.com/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: 'put',
updateIncidentUrl: 'http://some.non.existent.com/{{{external.system.id}}}',
},
});
export const getMappings = () => [
{
source: 'title',

View file

@ -18,6 +18,7 @@ import {
getServiceNowSIRConnector,
getEmailConnector,
getCaseConnectors,
getCasesWebhookConnector,
} from '../../../../common/lib/utils';
// eslint-disable-next-line import/no-default-export
@ -40,6 +41,10 @@ export default ({ getService }: FtrProviderContext): void => {
const jiraConnector = await createConnector({ supertest, req: getJiraConnector() });
const resilientConnector = await createConnector({ supertest, req: getResilientConnector() });
const sir = await createConnector({ supertest, req: getServiceNowSIRConnector() });
const casesWebhookConnector = await createConnector({
supertest,
req: getCasesWebhookConnector(),
});
actionsRemover.add('default', sir.id, 'action', 'actions');
actionsRemover.add('default', snConnector.id, 'action', 'actions');
@ -47,11 +52,42 @@ export default ({ getService }: FtrProviderContext): void => {
actionsRemover.add('default', emailConnector.id, 'action', 'actions');
actionsRemover.add('default', jiraConnector.id, 'action', 'actions');
actionsRemover.add('default', resilientConnector.id, 'action', 'actions');
actionsRemover.add('default', casesWebhookConnector.id, 'action', 'actions');
const connectors = await getCaseConnectors({ supertest });
const sortedConnectors = connectors.sort((a, b) => a.name.localeCompare(b.name));
expect(sortedConnectors).to.eql([
{
id: casesWebhookConnector.id,
actionTypeId: '.cases-webhook',
name: 'Cases Webhook Connector',
config: {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: 'post',
createCommentUrl: 'http://some.non.existent.com/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: 'post',
createIncidentResponseKey: 'id',
createIncidentUrl: 'http://some.non.existent.com/',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: { [`content-type`]: 'application/json' },
incidentViewUrl: 'http://some.non.existent.com/browse/{{{external.system.title}}}',
getIncidentUrl: 'http://some.non.existent.com/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: 'put',
updateIncidentUrl: 'http://some.non.existent.com/{{{external.system.id}}}',
},
isPreconfigured: false,
isDeprecated: false,
isMissingSecrets: false,
referencedByCount: 0,
},
{
id: jiraConnector.id,
actionTypeId: '.jira',

View file

@ -20,6 +20,7 @@ import {
getCaseConnectors,
getActionsSpace,
getEmailConnector,
getCasesWebhookConnector,
} from '../../../../common/lib/utils';
// eslint-disable-next-line import/no-default-export
@ -70,12 +71,19 @@ export default ({ getService }: FtrProviderContext): void => {
auth: authSpace1,
});
const casesWebhookConnector = await createConnector({
supertest: supertestWithoutAuth,
req: getCasesWebhookConnector(),
auth: authSpace1,
});
actionsRemover.add(space, sir.id, 'action', 'actions');
actionsRemover.add(space, snConnector.id, 'action', 'actions');
actionsRemover.add(space, snOAuthConnector.id, 'action', 'actions');
actionsRemover.add(space, emailConnector.id, 'action', 'actions');
actionsRemover.add(space, jiraConnector.id, 'action', 'actions');
actionsRemover.add(space, resilientConnector.id, 'action', 'actions');
actionsRemover.add(space, casesWebhookConnector.id, 'action', 'actions');
const connectors = await getCaseConnectors({
supertest: supertestWithoutAuth,
@ -84,6 +92,36 @@ export default ({ getService }: FtrProviderContext): void => {
const sortedConnectors = connectors.sort((a, b) => a.name.localeCompare(b.name));
expect(sortedConnectors).to.eql([
{
id: casesWebhookConnector.id,
actionTypeId: '.cases-webhook',
name: 'Cases Webhook Connector',
config: {
createCommentJson: '{"body":{{{case.comment}}}}',
createCommentMethod: 'post',
createCommentUrl: 'http://some.non.existent.com/{{{external.system.id}}}/comment',
createIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
createIncidentMethod: 'post',
createIncidentResponseKey: 'id',
createIncidentUrl: 'http://some.non.existent.com/',
getIncidentResponseCreatedDateKey: 'fields.created',
getIncidentResponseExternalTitleKey: 'key',
getIncidentResponseUpdatedDateKey: 'fields.updated',
hasAuth: true,
headers: { [`content-type`]: 'application/json' },
incidentViewUrl: 'http://some.non.existent.com/browse/{{{external.system.title}}}',
getIncidentUrl: 'http://some.non.existent.com/{{{external.system.id}}}',
updateIncidentJson:
'{"fields":{"summary":{{{case.title}}},"description":{{{case.description}}},"project":{"key":"ROC"},"issuetype":{"id":"10024"}}}',
updateIncidentMethod: 'put',
updateIncidentUrl: 'http://some.non.existent.com/{{{external.system.id}}}',
},
isPreconfigured: false,
isDeprecated: false,
isMissingSecrets: false,
referencedByCount: 0,
},
{
id: jiraConnector.id,
actionTypeId: '.jira',