diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/api.test.ts new file mode 100644 index 00000000000000..4764d9acdb2357 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/api.test.ts @@ -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; + +describe('api', () => { + let externalService: jest.Mocked; + + 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', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/api.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/api.ts new file mode 100644 index 00000000000000..109c1afa920b67 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/api.ts @@ -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 => { + 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, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/index.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/index.ts new file mode 100644 index 00000000000000..c9b6c41d478cfb --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/index.ts @@ -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> { + 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 }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/mock.ts new file mode 100644 index 00000000000000..20a5bcdfc1162f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/mock.ts @@ -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 => { + 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; diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/schema.ts new file mode 100644 index 00000000000000..fafa0a64101b63 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/schema.ts @@ -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, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/service.test.ts new file mode 100644 index 00000000000000..cf8464d71d8a2c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/service.test.ts @@ -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; + +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' + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/service.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/service.ts new file mode 100644 index 00000000000000..04b3e2fdbaff98 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/service.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/translations.ts new file mode 100644 index 00000000000000..06fd81491c0043 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/translations.ts @@ -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, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/types.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/types.ts new file mode 100644 index 00000000000000..1ea2b515e3ecd3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/types.ts @@ -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; + +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; + +export type ExecutorParams = TypeOf; +export type ExecutorSubActionPushParams = TypeOf; +export type PushToServiceApiParams = ExecutorSubActionPushParams; + +// incident service +export interface ExternalService { + createComment: (params: CreateCommentParams) => Promise; + createIncident: (params: CreateIncidentParams) => Promise; + getIncident: (id: string) => Promise; + updateIncident: (params: UpdateIncidentParams) => Promise; +} +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; +} + +export type CasesWebhookExecutorResultData = ExternalServiceIncidentResponse; diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/utils.test.ts new file mode 100644 index 00000000000000..29c732c1b7d400 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/utils.test.ts @@ -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"', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/utils.ts new file mode 100644 index 00000000000000..5833db7b6358e4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/utils.ts @@ -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 | 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) => ({ + case: Object.entries(properties).reduce( + (acc, [key, value]) => ({ ...acc, [key]: JSON.stringify(value) }), + {} + ), +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/validators.ts new file mode 100644 index 00000000000000..618ef2428f5fdd --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/cases_webhook/validators.ts @@ -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`); + } +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 26ca6b7b1820ef..587fdd4f20d2d7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -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 })); diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 3b9869be91413f..d04d0209cba45d 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -26,6 +26,8 @@ export type { } from './types'; export type { + CasesWebhookActionTypeId, + CasesWebhookActionParams, EmailActionTypeId, EmailActionParams, IndexActionTypeId, diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts index 2dff9bac3eddf0..154937c6f8065f 100644 --- a/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts @@ -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": "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 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, diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.ts index 24c18f7654d4fd..3602cebaa7bf18 100644 --- a/x-pack/plugins/actions/server/lib/mustache_renderer.ts +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.ts @@ -11,6 +11,17 @@ import { isString, isPlainObject, cloneDeepWith } from 'lodash'; export type Escape = 'markdown' | 'slack' | 'json' | 'none'; type Variables = Record; +// 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); diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index df9a7b0e24fd7c..e1e110913de749 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -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; export type CaseConnector = rt.TypeOf; export type ConnectorTypeFields = rt.TypeOf; +export type ConnectorCasesWebhookTypeFields = rt.TypeOf; export type ConnectorJiraTypeFields = rt.TypeOf; export type ConnectorResilientTypeFields = rt.TypeOf; export type ConnectorSwimlaneTypeFields = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/connectors/jira.ts b/x-pack/plugins/cases/common/api/connectors/jira.ts index b8a8d0147420eb..15a6768b075616 100644 --- a/x-pack/plugins/cases/common/api/connectors/jira.ts +++ b/x-pack/plugins/cases/common/api/connectors/jira.ts @@ -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]), diff --git a/x-pack/plugins/cases/common/api/connectors/resilient.ts b/x-pack/plugins/cases/common/api/connectors/resilient.ts index d28c96968d6e59..d19aa5b21fb52c 100644 --- a/x-pack/plugins/cases/common/api/connectors/resilient.ts +++ b/x-pack/plugins/cases/common/api/connectors/resilient.ts @@ -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]), diff --git a/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts b/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts index 6a5453561c73da..c3cf298a6aade5 100644 --- a/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts +++ b/x-pack/plugins/cases/common/api/connectors/servicenow_itsm.ts @@ -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]), diff --git a/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts b/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts index 3da8e694b473df..749abdea87437b 100644 --- a/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts +++ b/x-pack/plugins/cases/common/api/connectors/servicenow_sir.ts @@ -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]), diff --git a/x-pack/plugins/cases/common/api/connectors/swimlane.ts b/x-pack/plugins/cases/common/api/connectors/swimlane.ts index bc4d9df9ae6a0f..a5bf60edbf1cdd 100644 --- a/x-pack/plugins/cases/common/api/connectors/swimlane.ts +++ b/x-pack/plugins/cases/common/api/connectors/swimlane.ts @@ -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]), }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx index 4b608246a4c222..f0772195fc5222 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.tsx @@ -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 = ({ @@ -129,6 +129,7 @@ const ConnectorsComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx index 6299429612be82..22ebc5412dcc5f 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/mapping.test.tsx @@ -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, }; diff --git a/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx b/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx index 4ce971d6528794..1f7736041d5612 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/mapping.tsx @@ -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 = ({ actionTypeName, isLoading, mappings }) => { +const MappingComponent: React.FC = ({ + actionTypeName, + connectorType, + isLoading, + mappings, +}) => { const fieldMappingDesc: { desc: string; color: TextColor } = useMemo( () => mappings.length > 0 || isLoading @@ -29,12 +36,18 @@ const MappingComponent: React.FC = ({ 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 ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index ba457905590ab7..ada2690ab7b038 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -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.', + } +); diff --git a/x-pack/plugins/cases/public/components/connectors/cases_webhook/case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/cases_webhook/case_fields.tsx new file mode 100644 index 00000000000000..f0410839517f2a --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/cases_webhook/case_fields.tsx @@ -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> = ({ + connector, + isEdit = true, +}) => ( + <> + {!isEdit && ( + <> + + + {(!connector.config?.createCommentUrl || !connector.config?.createCommentJson) && ( + +

{i18n.CREATE_COMMENT_WARNING_DESC(connector.name)}

+
+ )} + + )} + +); +CasesWebhookComponent.displayName = 'CasesWebhook'; + +// eslint-disable-next-line import/no-default-export +export { CasesWebhookComponent as default }; diff --git a/x-pack/plugins/cases/public/components/connectors/cases_webhook/index.ts b/x-pack/plugins/cases/public/components/connectors/cases_webhook/index.ts new file mode 100644 index 00000000000000..e884ef36841714 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/cases_webhook/index.ts @@ -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 => { + return { + id: ConnectorTypes.casesWebhook, + fieldsComponent: lazy(() => import('./case_fields')), + }; +}; diff --git a/x-pack/plugins/cases/public/components/connectors/cases_webhook/translations.ts b/x-pack/plugins/cases/public/components/connectors/cases_webhook/translations.ts new file mode 100644 index 00000000000000..2a66acf62b780f --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/cases_webhook/translations.ts @@ -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.', + }); diff --git a/x-pack/plugins/cases/public/components/connectors/index.ts b/x-pack/plugins/cases/public/components/connectors/index.ts index 03d18976c40fd3..f70773c4864d23 100644 --- a/x-pack/plugins/cases/public/components/connectors/index.ts +++ b/x-pack/plugins/cases/public/components/connectors/index.ts @@ -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(getServiceNowSIRCaseConnector()); this.caseConnectorsRegistry.register(getSwimlaneCaseConnector()); + this.caseConnectorsRegistry.register(getCasesWebhookCaseConnector()); } registry(): CaseConnectorsRegistry { diff --git a/x-pack/plugins/cases/server/connectors/cases_webook/format.ts b/x-pack/plugins/cases/server/connectors/cases_webook/format.ts new file mode 100644 index 00000000000000..2356df109dd008 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases_webook/format.ts @@ -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, + }; +}; diff --git a/x-pack/plugins/cases/server/connectors/cases_webook/index.ts b/x-pack/plugins/cases/server/connectors/cases_webook/index.ts new file mode 100644 index 00000000000000..961e7648d0cef7 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases_webook/index.ts @@ -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, +}); diff --git a/x-pack/plugins/cases/server/connectors/cases_webook/mapping.ts b/x-pack/plugins/cases/server/connectors/cases_webook/mapping.ts new file mode 100644 index 00000000000000..50bd66fdcec8c4 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases_webook/mapping.ts @@ -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 = () => []; diff --git a/x-pack/plugins/cases/server/connectors/cases_webook/types.ts b/x-pack/plugins/cases/server/connectors/cases_webook/types.ts new file mode 100644 index 00000000000000..61d74070370dc0 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases_webook/types.ts @@ -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']; diff --git a/x-pack/plugins/cases/server/connectors/factory.ts b/x-pack/plugins/cases/server/connectors/factory.ts index 40a6702f11b0f6..40035fb984863d 100644 --- a/x-pack/plugins/cases/server/connectors/factory.ts +++ b/x-pack/plugins/cases/server/connectors/factory.ts @@ -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.casesWebhook]: getCasesWebhookCaseConnector(), [ConnectorTypes.jira]: getJiraCaseConnector(), [ConnectorTypes.serviceNowITSM]: getServiceNowITSMCaseConnector(), [ConnectorTypes.serviceNowSIR]: getServiceNowSIRCaseConnector(), diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5c7d7b551ec18e..d13f855d4dde6c 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fe46cfda3399e8..0c1e107290de6a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "警告", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8968f2f5b6ddb7..7f002a6e9a6fa3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "警告", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx index 39867ca690f0fc..ecc6de82514742 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.test.tsx @@ -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( + + ); + + expect(wrapper.find('[data-test-subj="fooAddVariableButton-Title"]').exists()).toEqual(true); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx index 0849be3d0f6a9e..6cfcf09d7387ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx @@ -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 = ({ + buttonTitle, messageVariables, paramsProperty, onSelectEventHandler, + showButtonTitle = false, }) => { const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); @@ -54,20 +59,34 @@ export const AddMessageVariables: React.FunctionComponent = ({ )); - const addVariableButtonTitle = i18n.translate( - 'xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle', - { - defaultMessage: 'Add rule variable', - } - ); - - if ((messageVariables?.length ?? 0) === 0) { - return <>; - } + const addVariableButtonTitle = buttonTitle + ? buttonTitle + : i18n.translate( + 'xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle', + { + defaultMessage: 'Add rule variable', + } + ); - return ( - + showButtonTitle ? ( + setIsVariablesPopoverOpen(true)} + iconType="indexOpen" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton', + { + defaultMessage: 'Add variable', + } + )} + > + {addVariableButtonTitle} + + ) : ( = ({ } )} /> - } + ), + [addVariableButtonTitle, paramsProperty, showButtonTitle] + ); + if ((messageVariables?.length ?? 0) === 0) { + return <>; + } + + return ( + setIsVariablesPopoverOpen(false)} panelPaddingSize="none" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/action_variables.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/action_variables.ts new file mode 100644 index 00000000000000..7ec8b3e3d5f6d1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/action_variables.ts @@ -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, + }, +]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/index.ts new file mode 100644 index 00000000000000..63e1475a115fd6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/index.ts @@ -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'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/auth.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/auth.tsx new file mode 100644 index 00000000000000..b356fb716d06f1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/auth.tsx @@ -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 = ({ display, readOnly }) => { + const { getFieldDefaultValue } = useFormContext(); + const [{ config, __internal__ }] = useFormData({ + watch: ['config.hasAuth', '__internal__.hasHeaders'], + }); + + const hasHeadersDefaultValue = !!getFieldDefaultValue('config.headers'); + + const hasAuth = config == null ? true : config.hasAuth; + const hasHeaders = __internal__ != null ? __internal__.hasHeaders : false; + + return ( + + + + +

{i18n.AUTH_TITLE}

+
+ + +
+
+ {hasAuth ? ( + + + + + + + + + ) : null} + + + + {hasHeaders ? ( + + {({ items, addItem, removeItem }) => { + return ( + <> + +
{i18n.HEADERS_TITLE}
+
+ + {items.map((item) => ( + + + + + + + + + removeItem(item.id)} + iconType="minusInCircle" + aria-label={i18n.DELETE_BUTTON} + style={{ marginTop: '28px' }} + /> + + + ))} + + + {i18n.ADD_BUTTON} + + + + ); + }} +
+ ) : null} +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/create.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/create.tsx new file mode 100644 index 00000000000000..c754a89ed89fec --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/create.tsx @@ -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 = ({ display, readOnly }) => ( + + +

{i18n.STEP_2}

+ +

{i18n.STEP_2_DESCRIPTION}

+
+
+ + + + ({ text: verb.toUpperCase(), value: verb })), + readOnly, + }, + }} + /> + + + + + + + + + + + + + + + +
+); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/get.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/get.tsx new file mode 100644 index 00000000000000..b6f50715355faf --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/get.tsx @@ -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 = ({ display, readOnly }) => ( + + +

{i18n.STEP_3}

+ +

{i18n.STEP_3_DESCRIPTION}

+
+
+ + + + + + + + + + + + + + + + + +
+); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/index.ts new file mode 100644 index 00000000000000..513f4ce45ca410 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/index.ts @@ -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'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/update.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/update.tsx new file mode 100644 index 00000000000000..c8bfa4ad350b13 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/steps/update.tsx @@ -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 = ({ display, readOnly }) => ( + + +

{i18n.STEP_4A}

+ +

{i18n.STEP_4A_DESCRIPTION}

+
+
+ + + + ({ text: verb.toUpperCase(), value: verb })), + readOnly, + }, + }} + /> + + + + + + + + + + + + +

{i18n.STEP_4B}

+ +

{i18n.STEP_4B_DESCRIPTION}

+
+
+ + + + ({ text: verb.toUpperCase(), value: verb })), + readOnly, + }, + }} + /> + + + + + + + + + + +
+); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/translations.ts new file mode 100644 index 00000000000000..1e21e64228b176 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/translations.ts @@ -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 service’s 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 service’s 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.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/types.ts new file mode 100644 index 00000000000000..b1867c28263e47 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/types.ts @@ -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 +>; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/validator.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/validator.ts new file mode 100644 index 00000000000000..7f34f76807e556 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/validator.ts @@ -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): ReturnType> => { + 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): ReturnType> => { + 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): ReturnType> => { + 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): ReturnType> => { + 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) => { + const [{ value }] = args; + const error: ValidationError = { + code: 'ERR_FIELD_FORMAT', + formatType: 'URL', + message, + }; + if (typeof value !== 'string') { + return error; + } + if (value.length === 0) { + return undefined; + } + return isUrl(value) ? undefined : error; + }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook.test.tsx new file mode 100644 index 00000000000000..45169e0fcb0321 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook.test.tsx @@ -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(); + 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.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook.tsx new file mode 100644 index 00000000000000..5ac8d915e26d9f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook.tsx @@ -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> => { + const translations = await import('./translations'); + const errors = { + 'subActionParams.incident.title': new Array(), + }; + 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')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_connectors.test.tsx new file mode 100644 index 00000000000000..3ce481f3b3466c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_connectors.test.tsx @@ -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 ; + }, + }; +}); + +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( + + {}} + /> + + ); + 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( + + {}} + /> + + ); + 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( + + {}} + /> + + ); + 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( + + {}} + /> + + ); + 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( + + {}} + /> + + ); + 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( + + {}} + /> + + ); + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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( + + {}} + /> + + ); + + 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(); + } + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_connectors.tsx new file mode 100644 index 00000000000000..d828f8226b8dfb --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_connectors.tsx @@ -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 = ({ + readOnly, +}) => { + const { docLinks } = useKibana().services; + const { isValid, getFields, validateFields } = useFormContext(); + const [currentStep, setCurrentStep] = useState(1); + const [status, setStatus] = useState>({ + 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, 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 ( + <> + + + {i18n.DOC_LINK} + + + + + + + + {currentStep < 4 && ( + + onNextStep()} + > + {i18n.NEXT} + + + )} + {currentStep > 1 && ( + + onNextStep((currentStep - 1) as PossibleStepNumbers)} + > + {i18n.PREVIOUS} + + + )} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { CasesWebhookActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_params.test.tsx new file mode 100644 index 00000000000000..91adb9616c4ab9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_params.test.tsx @@ -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 ; + }, + }; +}); + +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( + {}} + 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( + {}} + index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} + /> + ); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').first().prop('disabled')).toEqual( + true + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_params.tsx new file mode 100644 index 00000000000000..2d1f8b03bd08fd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/cases_webhook/webhook_params.tsx @@ -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> = ({ + 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 ( + <> + 0 && + incident.title !== undefined + } + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhook.titleFieldLabel', + { + defaultMessage: 'Summary (required)', + } + )} + > + + + + + { + 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" + /> + + <> + 0 ? comments[0].comment : undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.casesWebhook.commentsTextAreaFieldLabel', + { + defaultMessage: 'Additional comments', + } + )} + /> + {(!createCommentUrl || !createCommentJson) && ( + <> + + +

{CREATE_COMMENT_WARNING_DESC}

+
+ + )} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { WebhookParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index e2089221b4d60c..e5e5da50eca0bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -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()); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx index ba3470d4a1dfd2..135d4783da8228 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx @@ -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 = ({ + buttonTitle, messageVariables, paramsProperty, inputTargetValue, @@ -62,6 +66,8 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ onDocumentsChange, helpText, onBlur, + showButtonTitle, + euiCodeEditorProps = {}, }) => { const editorRef = useRef(); const editorDisposables = useRef([]); @@ -148,9 +154,11 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ label={label} labelAppend={ } helpText={helpText} @@ -177,6 +185,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ height="200px" data-test-subj={`${paramsProperty}JsonEditor`} aria-label={areaLabel} + {...euiCodeEditorProps} editorDidMount={onEditorMount} onChange={(xjson: string) => { setXJson(xjson); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_field_wrapper.styles.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/json_field_wrapper.styles.ts new file mode 100644 index 00000000000000..4ae891adcd3ed7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_field_wrapper.styles.ts @@ -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; + } + `, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_field_wrapper.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/json_field_wrapper.tsx new file mode 100644 index 00000000000000..223aee0648efac --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_field_wrapper.tsx @@ -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; + 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 ( + + {helpText}

} + inputTargetValue={value} + label={ + label ?? + i18n.translate('xpack.triggersActionsUI.jsonFieldWrapper.defaultLabel', { + defaultMessage: 'JSON Editor', + }) + } + onDocumentsChange={onJsonUpdate} + {...rest} + /> +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/mustache_text_field_wrapper.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/mustache_text_field_wrapper.tsx new file mode 100644 index 00000000000000..c5f5f3d5259968 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/mustache_text_field_wrapper.tsx @@ -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; + 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 ( + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx index 77938165cf264c..3bb65037d5c93f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx @@ -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 = ({ paramsProperty, index, inputTargetValue, + isDisabled = false, editAction, label, errors, @@ -52,6 +54,7 @@ export const TextAreaWithMessageVariables: React.FunctionComponent = ({ 0 && inputTargetValue !== undefined} label={label} labelAppend={ @@ -63,6 +66,7 @@ export const TextAreaWithMessageVariables: React.FunctionComponent = ({ } > 0 && inputTargetValue !== undefined} name={paramsProperty} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx index e8d43107bc11cc..0efe53603085e4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx @@ -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 ? ( + + {children} + + ) : ( + <>{children} + ); + export const TextFieldWithMessageVariables: React.FunctionComponent = ({ + buttonTitle, messageVariables, paramsProperty, index, inputTargetValue, editAction, errors, + formRowProps, defaultValue, + wrapField = false, + showButtonTitle, }) => { const [currentTextElement, setCurrentTextElement] = useState(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) => { editAction(paramsProperty, e.target.value, index); }; + const VariableButton = useMemo( + () => ( + + ), + [buttonTitle, messageVariables, onSelectMessageVariable, paramsProperty, showButtonTitle] + ); return ( - 0 && inputTargetValue !== undefined} - data-test-subj={`${paramsProperty}Input`} - value={inputTargetValue || ''} - defaultValue={defaultValue} - onChange={(e: React.ChangeEvent) => onChangeWithMessageVariable(e)} - onFocus={(e: React.FocusEvent) => { - setCurrentTextElement(e.target); - }} - onBlur={(e: React.FocusEvent) => { - if (!inputTargetValue) { - editAction(paramsProperty, '', index); - } - }} - append={ - - } - /> + + 0 && inputTargetValue !== undefined} + data-test-subj={`${paramsProperty}Input`} + value={inputTargetValue || ''} + defaultValue={defaultValue} + onChange={(e: React.ChangeEvent) => onChangeWithMessageVariable(e)} + onFocus={(e: React.FocusEvent) => { + setCurrentTextElement(e.target); + }} + onBlur={(e: React.FocusEvent) => { + if (!inputTargetValue) { + editAction(paramsProperty, '', index); + } + }} + append={wrapField ? undefined : VariableButton} + /> + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 1e1ee2aafe9fa1..60b153badffe67 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -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 = ( } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx new file mode 100644 index 00000000000000..d88be2a9759ff2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/beta_badge_props.tsx @@ -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.', + }), +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx index f3dabe0b1284cc..b0d8072dae6527 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx @@ -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; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx index d4897121199a94..e0cd2a77cc265d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/header.tsx @@ -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 = ({ + icon, + actionTypeName, + actionTypeMessage, + featureIds, + isExperimental, +}) => { return ( @@ -85,9 +96,17 @@ const FlyoutHeaderComponent: React.FC<{ )}
+ {actionTypeName && isExperimental && ( + + + + )}
); }; -export const FlyoutHeader = memo(FlyoutHeaderComponent); +export const FlyoutHeader: React.NamedExoticComponent = memo(FlyoutHeaderComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx index 0d82d73222ff89..f34c5d4e6dc761 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx @@ -157,6 +157,7 @@ const CreateConnectorFlyoutComponent: React.FC = ({ actionTypeName={actionType?.name} actionTypeMessage={actionTypeModel?.selectMessage} featureIds={actionType?.supportedFeatureIds} + isExperimental={actionTypeModel?.isExperimental} /> : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index f3b6d6e3a17d3a..78828680f364fd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -194,6 +194,7 @@ export interface ActionTypeModel | null; actionParamsFields: React.LazyExoticComponent>>; customConnectorSelectItem?: CustomConnectorSelectionItem; + isExperimental?: boolean; } export interface GenericValidationResult { diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/cases_webhook.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/cases_webhook.ts new file mode 100644 index 00000000000000..f767382a779ac2 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/cases_webhook.ts @@ -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 = ''; + 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.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts index 21cb0db3057bbe..8251a1b6fb3f19 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts @@ -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')); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 0c5f95189ae902..f0cbe467736453 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -30,6 +30,7 @@ interface CreateTestConfigOptions { // test.not-enabled is specifically not enabled const enabledActionTypes = [ + '.cases-webhook', '.email', '.index', '.pagerduty', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/builtin_action_types/cases_webhook.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/builtin_action_types/cases_webhook.ts new file mode 100644 index 00000000000000..89c92b22659837 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/builtin_action_types/cases_webhook.ts @@ -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 = ''; + let simulatorConfig: Record>; + 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(); + } + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 8175445b4f1c0c..922b3500266ff7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -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')); diff --git a/x-pack/test/cases_api_integration/common/config.ts b/x-pack/test/cases_api_integration/common/config.ts index a20dd300a4e6ee..6d3f59f30dc75a 100644 --- a/x-pack/test/cases_api_integration/common/config.ts +++ b/x-pack/test/cases_api_integration/common/config.ts @@ -21,6 +21,7 @@ interface CreateTestConfigOptions { } const enabledActionTypes = [ + '.cases-webhook', '.email', '.index', '.jira', diff --git a/x-pack/test/cases_api_integration/common/lib/utils.ts b/x-pack/test/cases_api_integration/common/lib/utils.ts index fd2fc0ba413e01..5f68fafff85a45 100644 --- a/x-pack/test/cases_api_integration/common/lib/utils.ts +++ b/x-pack/test/cases_api_integration/common/lib/utils.ts @@ -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', diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts index de72e0f343026b..4109913d9f4fb3 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts @@ -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', diff --git a/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts b/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts index b08b6f21390d41..c6d2165f2bf8b3 100644 --- a/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts +++ b/x-pack/test/cases_api_integration/spaces_only/tests/trial/configure/get_connectors.ts @@ -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',