From 7be1efc3828037bee443f5ce2cf1bcff4d53c2aa Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 18 Jun 2019 18:05:03 -0400 Subject: [PATCH] Adds a slack action --- package.json | 1 + x-pack/plugins/actions/common/rpromise.js | 21 +++ .../incoming_webhook.mock.ts | 25 ++++ .../server/builtin_action_types/index.ts | 2 + .../server/builtin_action_types/slack.test.ts | 141 ++++++++++++++++++ .../server/builtin_action_types/slack.ts | 47 ++++++ .../actions/builtin_action_types/slack.ts | 69 +++++++++ .../api_integration/apis/actions/index.ts | 1 + yarn.lock | 16 +- 9 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/actions/common/rpromise.js create mode 100644 x-pack/plugins/actions/server/builtin_action_types/incoming_webhook.mock.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/slack.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/slack.ts create mode 100644 x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts diff --git a/package.json b/package.json index 82246d9fd1cb6..3ea4c24e40cba 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "@kbn/pm": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", + "@slack/webhook": "^5.0.0", "@types/json-stable-stringify": "^1.0.32", "@types/lodash.clonedeep": "^4.5.4", "@types/react-grid-layout": "^0.16.7", diff --git a/x-pack/plugins/actions/common/rpromise.js b/x-pack/plugins/actions/common/rpromise.js new file mode 100644 index 0000000000000..d15bcc996fc1e --- /dev/null +++ b/x-pack/plugins/actions/common/rpromise.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// a function which returns a promise with a resolve() and reject() method +export function rPromise() { + let resolver; + let rejecter; + + const promise = new Promise((resolve, reject) => { + resolver = resolve; + rejecter = reject; + }); + + promise.resolve = resolver; + promise.reject = rejecter; + + return promise; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/incoming_webhook.mock.ts b/x-pack/plugins/actions/server/builtin_action_types/incoming_webhook.mock.ts new file mode 100644 index 0000000000000..af12b7a89765e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/incoming_webhook.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook'; + +// A mock version of the slacks IncomingWebhook API which succeeds or fails +// based on the the content of the message passed. +export class MockIncomingWebhook extends IncomingWebhook { + async send(message: string): Promise { + if (message == null) throw new Error('message property required in parameter'); + + const failureMatch = message.match(/^failure: (.*)$/); + if (failureMatch != null) { + const failMessage = failureMatch[1]; + throw new Error(`mockIncomingWebhook failure: ${failMessage}`); + } + + return { + text: `mockIncomingWebhook success: ${message}`, + }; + } +} 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 6f410d2ee20ec..b54a2ee366df6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -7,7 +7,9 @@ import { ActionTypeRegistry } from '../action_type_registry'; import { actionType as serverLogActionType } from './server_log'; +import { actionType as slackActionType } from './slack'; export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { actionTypeRegistry.register(serverLogActionType); + actionTypeRegistry.register(slackActionType); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts new file mode 100644 index 0000000000000..0f7d1b52e4a60 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionType } from '../types'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; +import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { validateActionTypeParams } from '../lib'; +import { validateActionTypeConfig } from '../lib'; +import { setIncomingWebhookImpl } from './slack'; +import { registerBuiltInActionTypes } from './index'; +import { MockIncomingWebhook } from './incoming_webhook.mock'; +import { taskManagerMock } from '../../../task_manager/task_manager.mock'; + +const ACTION_TYPE_ID = 'kibana.slack'; + +const NO_OP_FN = () => {}; + +const services = { + log: NO_OP_FN, + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: SavedObjectsClientMock.create(), +}; + +function getServices() { + return services; +} + +let actionTypeRegistry: ActionTypeRegistry; +let actionType: ActionType; + +const mockEncryptedSavedObjectsPlugin = { + getDecryptedAsInternalUser: jest.fn() as EncryptedSavedObjectsPlugin['getDecryptedAsInternalUser'], +} as EncryptedSavedObjectsPlugin; + +beforeAll(() => { + actionTypeRegistry = new ActionTypeRegistry({ + getServices, + taskManager: taskManagerMock.create(), + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + }); + registerBuiltInActionTypes(actionTypeRegistry); + setIncomingWebhookImpl(MockIncomingWebhook); + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +afterAll(() => { + setIncomingWebhookImpl(); +}); + +describe('action is registered', () => { + test('gets registered with builtin actions', () => { + expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true); + }); +}); + +describe('get()', () => { + test('returns action type', () => { + const returnedActionType = actionTypeRegistry.get(ACTION_TYPE_ID); + expect(returnedActionType.id).toEqual(ACTION_TYPE_ID); + expect(returnedActionType.name).toEqual('slack'); + }); +}); + +describe('validateParams()', () => { + test('ensure action type is valid', () => { + expect(actionType).toBeTruthy(); + }); + + test('should validate and pass when params is valid', () => { + expect(validateActionTypeParams(actionType, { message: 'a message' })).toEqual({ + message: 'a message', + }); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateActionTypeParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"message\\" fails because [\\"message\\" is required]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { message: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"message\\" fails because [\\"message\\" must be a string]"` + ); + }); +}); + +describe('validateActionTypeConfig()', () => { + test('should validate and pass when params is valid', () => { + validateActionTypeConfig(actionType, { + webhookUrl: 'https://example.com', + }); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateActionTypeConfig(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"The following actionTypeConfig attributes are invalid: webhookUrl [any.required]"` + ); + + expect(() => { + validateActionTypeConfig(actionType, { webhookUrl: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"The following actionTypeConfig attributes are invalid: webhookUrl [string.base]"` + ); + }); +}); + +describe('execute()', () => { + test('calls the mock executor with success', async () => { + const response = await actionType.executor({ + services, + config: { webhookUrl: 'http://example.com' }, + params: { message: 'this invocation should succeed' }, + }); + expect(response).toMatchInlineSnapshot(` +Object { + "text": "mockIncomingWebhook success: this invocation should succeed", +} +`); + }); + + test('calls the mock executor with failure', async () => { + await expect( + actionType.executor({ + services, + config: { webhookUrl: 'http://example.com' }, + params: { message: 'failure: this invocation should fail' }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"mockIncomingWebhook failure: this invocation should fail"` + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts new file mode 100644 index 0000000000000..677476f262332 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { IncomingWebhook } from '@slack/webhook'; + +import { ActionType, ActionTypeExecutorOptions } from '../types'; + +let IncomingWebhookImpl = IncomingWebhook; + +// for testing +export function setIncomingWebhookImpl(incomingWebHook: any = IncomingWebhook): void { + IncomingWebhookImpl = incomingWebHook; +} + +const CONFIG_SCHEMA = Joi.object().keys({ + webhookUrl: Joi.string().required(), +}); + +const PARAMS_SCHEMA = Joi.object().keys({ + message: Joi.string().required(), +}); + +export const actionType: ActionType = { + id: 'kibana.slack', + name: 'slack', + unencryptedAttributes: [], + validate: { + params: PARAMS_SCHEMA, + config: CONFIG_SCHEMA, + }, + executor, +}; + +async function executor({ config, params, services }: ActionTypeExecutorOptions): Promise { + const { webhookUrl } = config; + const { message } = params; + + // TODO: do we need an agent for proxy access? + const webhook = new IncomingWebhookImpl(webhookUrl); + + // TODO: should we have a standardize response for executor? + return await webhook.send(message); +} diff --git a/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts b/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts new file mode 100644 index 0000000000000..d7fad80894282 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { KibanaFunctionalTestDefaultProviders } from '../../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function serverLogTest({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('create slack action', () => { + it('should return 200 when creating a slack action successfully', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'A slack action', + actionTypeId: 'kibana.slack', + actionTypeConfig: { + webhookUrl: 'http://example.com', + }, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + type: 'action', + id: resp.body.id, + attributes: { + description: 'A slack action', + actionTypeId: 'kibana.slack', + actionTypeConfig: {}, + }, + references: [], + updated_at: resp.body.updated_at, + version: resp.body.version, + }); + expect(typeof resp.body.id).to.be('string'); + }); + }); + + it('should return error when creating a slack action with no webhookUrl', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'A slack action', + actionTypeId: 'kibana.slack', + actionTypeConfig: {}, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'The following actionTypeConfig attributes are invalid: webhookUrl [any.required]', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/index.ts b/x-pack/test/api_integration/apis/actions/index.ts index 86a19f2f749fe..d2f633a679300 100644 --- a/x-pack/test/api_integration/apis/actions/index.ts +++ b/x-pack/test/api_integration/apis/actions/index.ts @@ -16,5 +16,6 @@ export default function actionsTests({ loadTestFile }: KibanaFunctionalTestDefau loadTestFile(require.resolve('./list_action_types')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./builtin_action_types/server_log')); + loadTestFile(require.resolve('./builtin_action_types/slack')); }); } diff --git a/yarn.lock b/yarn.lock index ede899f04c75f..25de38627a836 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2698,6 +2698,20 @@ retry "^0.12.0" ws "^5.2.0" +"@slack/types@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-1.0.0.tgz#1dc7a63b293c4911e474197585c3feda012df17a" + integrity sha512-IktC4uD/CHfLQcSitKSmjmRu4a6+Nf/KzfS6dTgUlDzENhh26l8aESKAuIpvYD5VOOE6NxDDIAdPJOXBvUGxlg== + +"@slack/webhook@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@slack/webhook/-/webhook-5.0.0.tgz#0044a3940afc16cbc607c71acdffddb9e9d4f161" + integrity sha512-cDj3kz3x9z9271xPNzlwb90DpKTYybG2OWPJHigJL8FegR80rzQyD0v4bGuStGGkHbAYDKE2BMpJambR55hnSg== + dependencies: + "@slack/types" "^1.0.0" + "@types/node" ">=8.9.0" + axios "^0.18.0" + "@storybook/addon-actions@^5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-5.0.5.tgz#9179d08262c326c865021f5ecd173708c82edc87" @@ -3896,7 +3910,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@10.12.27", "@types/node@8.5.8", "@types/node@>=6.0.0", "@types/node@^10.12.27", "@types/node@^12.0.2": +"@types/node@*", "@types/node@10.12.27", "@types/node@8.5.8", "@types/node@>=6.0.0", "@types/node@>=8.9.0", "@types/node@^10.12.27", "@types/node@^12.0.2": version "10.12.27" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.27.tgz#eb3843f15d0ba0986cc7e4d734d2ee8b50709ef8" integrity sha512-e9wgeY6gaY21on3ve0xAjgBVjGDWq/xUteK0ujsE53bUoxycMkqfnkUgMt6ffZtykZ5X12Mg3T7Pw4TRCObDKg==