Skip to content

Commit

Permalink
Adds a slack action to the x-pack actions plugin (#39221) (#39625)
Browse files Browse the repository at this point in the history
  • Loading branch information
pmuellr authored Jun 25, 2019
1 parent 07a65ee commit 079de06
Show file tree
Hide file tree
Showing 9 changed files with 297 additions and 5 deletions.
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { ActionType } from '../types';
import { ActionType, Services } from '../types';
import { ActionTypeRegistry } from '../action_type_registry';
import { taskManagerMock } from '../../../task_manager/task_manager.mock';
import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects';
Expand All @@ -16,13 +16,13 @@ import { registerBuiltInActionTypes } from './index';
const ACTION_TYPE_ID = 'kibana.server-log';
const NO_OP_FN = () => {};

const services = {
const services: Services = {
log: NO_OP_FN,
callCluster: async (path: string, opts: any) => {},
savedObjectsClient: SavedObjectsClientMock.create(),
};

function getServices() {
function getServices(): Services {
return services;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* 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, Services, ActionTypeExecutorOptions } from '../types';
import { ActionTypeRegistry } from '../action_type_registry';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock';
import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks';
import { validateActionTypeParams } from '../lib';
import { validateActionTypeConfig } from '../lib';
import { getActionType } from './slack';
import { taskManagerMock } from '../../../task_manager/task_manager.mock';

const ACTION_TYPE_ID = '.slack';

const NO_OP_FN = () => {};

const services: Services = {
log: NO_OP_FN,
callCluster: async (path: string, opts: any) => {},
savedObjectsClient: SavedObjectsClientMock.create(),
};

function getServices(): Services {
return services;
}

let actionTypeRegistry: ActionTypeRegistry;
let actionType: ActionType;

const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create();

async function mockSlackExecutor(options: ActionTypeExecutorOptions): Promise<any> {
const { params } = options;
const { message } = params;
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(`slack mockExecutor failure: ${failMessage}`);
}

return {
text: `slack mockExecutor success: ${message}`,
};
}

beforeAll(() => {
actionTypeRegistry = new ActionTypeRegistry({
getServices,
taskManager: taskManagerMock.create(),
encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin,
});
actionTypeRegistry.register(getActionType({ executor: mockSlackExecutor }));
actionType = actionTypeRegistry.get(ACTION_TYPE_ID);

test('ensure action type is valid', () => {
expect(actionType).toBeTruthy();
});
});

describe('action is registered', () => {
test('gets registered with builtin actions', () => {
expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true);
});

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('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 config is valid', () => {
validateActionTypeConfig(actionType, {
webhookUrl: 'https://example.com',
});
});

test('should validate and throw error when config 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": "slack mockExecutor 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(
`"slack mockExecutor failure: this invocation should fail"`
);
});
});
54 changes: 54 additions & 0 deletions x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts
Original file line number Diff line number Diff line change
@@ -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;
* 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, ExecutorType } from '../types';

const CONFIG_SCHEMA = Joi.object()
.keys({
webhookUrl: Joi.string().required(),
})
.required();

const PARAMS_SCHEMA = Joi.object()
.keys({
message: Joi.string().required(),
})
.required();

// customizing executor is only used for tests
export function getActionType({ executor }: { executor?: ExecutorType } = {}): ActionType {
if (executor == null) executor = slackExecutor;

return {
id: '.slack',
name: 'slack',
unencryptedAttributes: [],
validate: {
params: PARAMS_SCHEMA,
config: CONFIG_SCHEMA,
},
executor,
};
}

// the production executor for this action
export const actionType = getActionType();

async function slackExecutor({
config,
params,
services,
}: ActionTypeExecutorOptions): Promise<any> {
const { webhookUrl } = config;
const { message } = params;

const webhook = new IncomingWebhook(webhookUrl);

return await webhook.send(message);
}
4 changes: 3 additions & 1 deletion x-pack/legacy/plugins/actions/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface ActionTypeExecutorOptions {
params: Record<string, any>;
}

export type ExecutorType = (options: ActionTypeExecutorOptions) => Promise<any>;

export interface ActionType {
id: string;
name: string;
Expand All @@ -41,5 +43,5 @@ export interface ActionType {
params?: any;
config?: any;
};
executor({ services, config, params }: ActionTypeExecutorOptions): Promise<any>;
executor: ExecutorType;
}
1 change: 1 addition & 0 deletions x-pack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"@samverschueren/stream-to-observable": "^0.3.0",
"@scant/router": "^0.1.0",
"@slack/client": "^4.8.0",
"@slack/webhook": "^5.0.0",
"@turf/boolean-contains": "6.0.1",
"angular-resource": "1.4.9",
"angular-sanitize": "1.6.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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 slackTest({ 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: '.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: '.slack',
actionTypeConfig: {},
},
references: [],
updated_at: resp.body.updated_at,
version: resp.body.version,
});
expect(typeof resp.body.id).to.be('string');
});
});

it('should respond with a 400 Bad Request 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: '.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]',
});
});
});
});

// TODO: once we have the HTTP API fire action, test that with a webhook url pointing
// back to the Kibana server
}
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 @@ -2677,6 +2677,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 @@ -3875,7 +3889,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 079de06

Please sign in to comment.