Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a slack action #39221

Merged
merged 4 commits into from
Jun 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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",
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 @@ -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"
Expand Down Expand Up @@ -3910,7 +3924,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