Adds a slack action to the x-pack actions plugin (#39221)

This commit is contained in:
Patrick Mueller 2019-06-25 17:10:28 -04:00 committed by GitHub
parent 4afed1af4b
commit 9ac21391f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 297 additions and 5 deletions

View file

@ -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);
}

View file

@ -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';
@ -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;
}

View file

@ -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"`
);
});
});

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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);
}

View file

@ -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;
@ -41,5 +43,5 @@ export interface ActionType {
params?: any;
config?: any;
};
executor({ services, config, params }: ActionTypeExecutorOptions): Promise<any>;
executor: ExecutorType;
}

View file

@ -187,6 +187,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",

View file

@ -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
}

View file

@ -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'));
});
}

View file

@ -2692,6 +2692,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"
@ -3890,7 +3904,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==