Skip to content

Commit

Permalink
Adds a slack action
Browse files Browse the repository at this point in the history
  • Loading branch information
pmuellr committed Jun 18, 2019
1 parent a762bff commit 7be1efc
Show file tree
Hide file tree
Showing 9 changed files with 322 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions x-pack/plugins/actions/common/rpromise.js
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<IncomingWebhookResult> {
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}`,
};
}
}
2 changes: 2 additions & 0 deletions x-pack/plugins/actions/server/builtin_action_types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
141 changes: 141 additions & 0 deletions x-pack/plugins/actions/server/builtin_action_types/slack.test.ts
Original file line number Diff line number Diff line change
@@ -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"`
);
});
});
47 changes: 47 additions & 0 deletions x-pack/plugins/actions/server/builtin_action_types/slack.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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);
}
Original file line number Diff line number Diff line change
@@ -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]',
});
});
});
});
}
1 change: 1 addition & 0 deletions x-pack/test/api_integration/apis/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
}
16 changes: 15 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -3896,7 +3910,7 @@
dependencies:
"@types/node" "*"

"@types/node@*", "@types/[email protected]", "@types/[email protected]", "@types/node@>=6.0.0", "@types/node@^10.12.27", "@types/node@^12.0.2":
"@types/node@*", "@types/[email protected]", "@types/[email protected]", "@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==
Expand Down

0 comments on commit 7be1efc

Please sign in to comment.