Remove notifications plugin (#41674)

The notifications functionality has been replaced by the features of the
actions plugin. This notifications plugin was never actually used by
end-user facing features of Kibana.
This commit is contained in:
Court Ewing 2019-07-22 14:19:20 -04:00 committed by GitHub
parent a177b86258
commit 6419c232e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 0 additions and 2802 deletions

View file

@ -26,7 +26,6 @@ import { indexManagement } from './legacy/plugins/index_management';
import { indexLifecycleManagement } from './legacy/plugins/index_lifecycle_management';
import { consoleExtensions } from './legacy/plugins/console_extensions';
import { spaces } from './legacy/plugins/spaces';
import { notifications } from './legacy/plugins/notifications';
import { kueryAutocomplete } from './legacy/plugins/kuery_autocomplete';
import { canvas } from './legacy/plugins/canvas';
import { infra } from './legacy/plugins/infra';
@ -70,7 +69,6 @@ module.exports = function (kibana) {
cloud(kibana),
indexManagement(kibana),
consoleExtensions(kibana),
notifications(kibana),
indexLifecycleManagement(kibana),
kueryAutocomplete(kibana),
infra(kibana),

View file

@ -1,51 +0,0 @@
/*
* 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.
*/
/**
* User-configurable settings for xpack.notifications via configuration schema
*
* @param {Object} Joi - HapiJS Joi module that allows for schema validation
* @return {Object} config schema
*/
export const config = (Joi) => {
return Joi.object({
enabled: Joi.boolean().default(true),
email: Joi.object({
enabled: Joi.boolean().default(false),
smtp: Joi.object({
host: Joi.string().default('localhost'),
port: Joi.number().default(25),
require_tls: Joi.boolean().default(false),
pool: Joi.boolean().default(false),
auth: Joi.object({
username: Joi.string(),
password: Joi.string()
}).default(),
}).default(),
defaults: Joi.object({
from: Joi.string(),
to: Joi.array().single().items(Joi.string()),
cc: Joi.array().single().items(Joi.string()),
bcc: Joi.array().single().items(Joi.string()),
}).default(),
}).default(),
slack: Joi.object({
enabled: Joi.boolean().default(false),
token: Joi.string().required(),
defaults: Joi.object({
channel: Joi.string(),
as_user: Joi.boolean().default(false),
icon_emoji: Joi.string(),
icon_url: Joi.string(),
link_names: Joi.boolean().default(true),
mrkdwn: Joi.boolean().default(true),
unfurl_links: Joi.boolean().default(true),
unfurl_media: Joi.boolean().default(true),
username: Joi.string(),
}).default(),
})
}).default();
};

View file

@ -1,24 +0,0 @@
/*
* 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 { resolve } from 'path';
import { init } from './init';
import { config } from './config';
/**
* Invokes plugin modules to instantiate the Notification plugin for Kibana
*
* @param kibana {Object} Kibana plugin instance
* @return {Object} Notification Kibana plugin object
*/
export const notifications = (kibana) => new kibana.Plugin({
require: ['kibana', 'xpack_main'],
id: 'notifications',
configPrefix: 'xpack.notifications',
publicDir: resolve(__dirname, 'public'),
init,
config,
});

View file

@ -1,38 +0,0 @@
/*
* 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 {
notificationService,
createEmailAction,
createSlackAction,
LoggerAction,
} from './server';
import { notificationServiceSendRoute } from './server/routes/api/v1/notifications';
/**
* Initialize the Action Service with various actions provided by X-Pack, when configured.
*
* @param server {Object} HapiJS server instance
*/
export function init(server) {
const config = server.config();
// the logger
notificationService.setAction(new LoggerAction({ server }));
if (config.get('xpack.notifications.email.enabled')) {
notificationService.setAction(createEmailAction(server));
}
if (config.get('xpack.notifications.slack.enabled')) {
notificationService.setAction(createSlackAction(server));
}
notificationServiceSendRoute(server, notificationService);
// expose the notification service for other plugins
server.expose('notificationService', notificationService);
}

View file

@ -1,58 +0,0 @@
/*
* 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 { EmailAction } from './email_action';
/**
* Create a Nodemailer transporter options object from the config.
*
* @param {Object} config The server configuration.
* @return {Object} An object that configures Nodemailer.
*/
export function optionsFromConfig(config) {
return {
host: config.get('xpack.notifications.email.smtp.host'),
port: config.get('xpack.notifications.email.smtp.port'),
requireTLS: config.get('xpack.notifications.email.smtp.require_tls'),
pool: config.get('xpack.notifications.email.smtp.pool'),
auth: {
user: config.get('xpack.notifications.email.smtp.auth.username'),
pass: config.get('xpack.notifications.email.smtp.auth.password'),
},
};
}
/**
* Create a Nodemailer defaults object from the config.
*
* Defaults include things like the default "from" email address.
*
* @param {Object} config The server configuration.
* @return {Object} An object that configures Nodemailer on a per-message basis.
*/
export function defaultsFromConfig(config) {
return {
from: config.get('xpack.notifications.email.defaults.from'),
to: config.get('xpack.notifications.email.defaults.to'),
cc: config.get('xpack.notifications.email.defaults.cc'),
bcc: config.get('xpack.notifications.email.defaults.bcc'),
};
}
/**
* Create a new Email Action based on the configuration.
*
* @param {Object} server The server object.
* @return {EmailAction} A new email action based on the kibana.yml configuration.
*/
export function createEmailAction(server, { _options = optionsFromConfig, _defaults = defaultsFromConfig } = { }) {
const config = server.config();
const options = _options(config);
const defaults = _defaults(config);
return new EmailAction({ server, options, defaults });
}

View file

@ -1,96 +0,0 @@
/*
* 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 { EmailAction } from './email_action';
import {
createEmailAction,
defaultsFromConfig,
optionsFromConfig,
} from './create_email_action';
describe('create_email_action', () => {
test('optionsFromConfig uses config without modification', () => {
const get = key => {
const suffixes = [
'host',
'port',
'require_tls',
'pool',
'auth.username',
'auth.password',
];
const value = suffixes.find(suffix => {
return `xpack.notifications.email.smtp.${suffix}` === key;
});
if (value === undefined) {
throw new Error(`Unknown config key used ${key}`);
}
return value;
};
expect(optionsFromConfig({ get })).toEqual({
host: 'host',
port: 'port',
requireTLS: 'require_tls',
pool: 'pool',
auth: {
user: 'auth.username',
pass: 'auth.password',
},
});
});
test('defaultsFromConfig uses config without modification', () => {
const get = key => {
const suffixes = [
'from',
'to',
'cc',
'bcc',
];
const value = suffixes.find(suffix => {
return `xpack.notifications.email.defaults.${suffix}` === key;
});
if (value === undefined) {
throw new Error(`Unknown config key used ${key}`);
}
return value;
};
expect(defaultsFromConfig({ get })).toEqual({
from: 'from',
to: 'to',
cc: 'cc',
bcc: 'bcc',
});
});
test('createEmailAction', async () => {
const config = { };
const server = { config: jest.fn().mockReturnValue(config) };
const _options = jest.fn().mockReturnValue({ options: true });
const defaults = { defaults: true };
const _defaults = jest.fn().mockReturnValue(defaults);
const action = createEmailAction(server, { _options, _defaults });
expect(action instanceof EmailAction).toBe(true);
expect(action.defaults).toBe(defaults);
expect(server.config).toHaveBeenCalledTimes(1);
expect(server.config).toHaveBeenCalledWith();
expect(_options).toHaveBeenCalledTimes(1);
expect(_options).toHaveBeenCalledWith(config);
expect(_defaults).toHaveBeenCalledTimes(1);
expect(_defaults).toHaveBeenCalledWith(config);
});
});

View file

@ -1,103 +0,0 @@
/*
* 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 nodemailer from 'nodemailer';
import { Action, ActionResult } from '../';
export const EMAIL_ACTION_ID = 'xpack-notifications-email';
/**
* Email Action enables generic sending of emails, when configured.
*/
export class EmailAction extends Action {
/**
* Create a new Action capable of sending emails.
*
* @param {Object} server Kibana server object.
* @param {Object} options Configuration options for Nodemailer.
* @param {Object} defaults Default fields used when sending emails.
* @param {Object} _nodemailer Exposed for tests.
*/
constructor({ server, options, defaults = { }, _nodemailer = nodemailer }) {
super({ server, id: EMAIL_ACTION_ID, name: 'Email' });
this.transporter = _nodemailer.createTransport(options, defaults);
this.defaults = defaults;
}
getMissingFields(notification) {
const missingFields = [];
if (!Boolean(this.defaults.to) && !Boolean(notification.to)) {
missingFields.push({
field: 'to',
name: 'To',
type: 'email',
});
}
if (!Boolean(this.defaults.from) && !Boolean(notification.from)) {
missingFields.push({
field: 'from',
name: 'From',
type: 'email',
});
}
if (!Boolean(notification.subject)) {
missingFields.push({
field: 'subject',
name: 'Subject',
type: 'text',
});
}
if (!Boolean(notification.markdown)) {
missingFields.push({
field: 'markdown',
name: 'Body',
type: 'markdown',
});
}
return missingFields;
}
async doPerformHealthCheck() {
// this responds with a boolean 'true' response, otherwise throws an Error
const response = await this.transporter.verify();
return new ActionResult({
message: `Email action SMTP configuration has been verified.`,
response: {
verified: response
},
});
}
async doPerformAction(notification) {
// Note: This throws an Error upon failure
const response = await this.transporter.sendMail({
// email routing
from: notification.from,
to: notification.to,
cc: notification.cc,
bcc: notification.bcc,
// email content
subject: notification.subject,
html: notification.markdown,
text: notification.markdown,
});
return new ActionResult({
message: `Sent email for '${notification.subject}'.`,
response,
});
}
}

View file

@ -1,166 +0,0 @@
/*
* 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 { ActionResult } from '../';
import { EMAIL_ACTION_ID, EmailAction } from './email_action';
describe('EmailAction', () => {
const server = { };
const options = { options: true };
const defaults = { defaults: true };
const transporter = {
// see beforeEach
};
const _nodemailer = {
// see beforeEach
};
let action;
beforeEach(() => {
transporter.verify = jest.fn();
transporter.sendMail = jest.fn();
_nodemailer.createTransport = jest.fn().mockReturnValue(transporter);
action = new EmailAction({ server, options, defaults, _nodemailer });
});
test('id and name to be from constructor', () => {
expect(action.getId()).toBe(EMAIL_ACTION_ID);
expect(action.getName()).toBe('Email');
expect(action.transporter).toBe(transporter);
expect(_nodemailer.createTransport).toHaveBeenCalledTimes(1);
expect(_nodemailer.createTransport).toHaveBeenCalledWith(options, defaults);
});
describe('getMissingFields', () => {
test('returns missing fields', () => {
const to = { field: 'to', name: 'To', type: 'email' };
const from = { field: 'from', name: 'From', type: 'email' };
const subject = { field: 'subject', name: 'Subject', type: 'text' };
const markdown = { field: 'markdown', name: 'Body', type: 'markdown' };
const missing = [
{ defaults: { }, notification: { }, missing: [ to, from, subject, markdown, ], },
{ defaults: { }, notification: { from: 'b@c.co', subject: 'subject', markdown: 'body', }, missing: [ to, ], },
{ defaults: { from: 'b@c.co', }, notification: { subject: 'subject', markdown: 'body', }, missing: [ to, ], },
{ defaults: { }, notification: { to: 'a@b.co', subject: 'subject', markdown: 'body', }, missing: [ from, ], },
{ defaults: { to: 'a@b.co', }, notification: { subject: 'subject', markdown: 'body', }, missing: [ from, ], },
{ defaults: { }, notification: { to: 'a@b.co', from: 'b@c.co', markdown: 'body', }, missing: [ subject, ], },
{ defaults: { }, notification: { to: 'a@b.co', from: 'b@c.co', subject: 'subject', }, missing: [ markdown, ], },
];
missing.forEach(check => {
const newDefaultsAction = new EmailAction({ server, defaults: check.defaults, _nodemailer });
expect(newDefaultsAction.getMissingFields(check.notification)).toEqual(check.missing);
});
});
test('returns [] when all fields exist', () => {
const exists = [
{ defaults: { }, notification: { to: 'a@b.co', from: 'b@c.co', subject: 'subject', markdown: 'body', }, },
{ defaults: { to: 'a@b.co', }, notification: { from: 'b@c.co', subject: 'subject', markdown: 'body', }, },
{ defaults: { from: 'b@c.co', }, notification: { to: 'a@b.co', subject: 'subject', markdown: 'body', }, },
{ defaults: { to: 'a@b.co', from: 'b@c.co', }, notification: { subject: 'subject', markdown: 'body', }, },
];
exists.forEach(check => {
const newDefaultsAction = new EmailAction({ server, defaults: check.defaults, _nodemailer });
expect(newDefaultsAction.getMissingFields(check.notification)).toEqual([]);
});
});
});
describe('doPerformHealthCheck', () => {
test('rethrows Error for failure', async () => {
const error = new Error('TEST - expected');
transporter.verify.mockRejectedValue(error);
await expect(action.doPerformHealthCheck())
.rejects
.toThrow(error);
expect(transporter.verify).toHaveBeenCalledTimes(1);
expect(transporter.verify).toHaveBeenCalledWith();
});
test('returns ActionResult for success', async () => {
transporter.verify.mockResolvedValue(true);
const result = await action.doPerformHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch('Email action SMTP configuration has been verified.');
expect(result.getResponse()).toEqual({
verified: true
});
expect(transporter.verify).toHaveBeenCalledTimes(1);
expect(transporter.verify).toHaveBeenCalledWith();
});
});
describe('doPerformAction', () => {
const email = { subject: 'email', markdown: 'body' };
test('rethrows Error for failure', async () => {
const error = new Error('TEST - expected');
transporter.sendMail.mockRejectedValue(error);
await expect(action.doPerformAction(email))
.rejects
.toThrow(error);
expect(transporter.sendMail).toHaveBeenCalledTimes(1);
expect(transporter.sendMail).toHaveBeenCalledWith({
to: undefined,
from: undefined,
cc: undefined,
bcc: undefined,
subject: email.subject,
html: email.markdown,
text: email.markdown,
});
});
test('returns ActionResult for success', async () => {
const response = { fake: true };
transporter.sendMail.mockResolvedValue(response);
const result = await action.doPerformAction(email);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch(`Sent email for '${email.subject}'.`);
expect(result.getResponse()).toBe(response);
expect(transporter.sendMail).toHaveBeenCalledTimes(1);
expect(transporter.sendMail).toHaveBeenCalledWith({
to: undefined,
from: undefined,
cc: undefined,
bcc: undefined,
subject: email.subject,
html: email.markdown,
text: email.markdown,
});
});
});
});

View file

@ -1,7 +0,0 @@
/*
* 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.
*/
export { createEmailAction } from './create_email_action';

View file

@ -1,14 +0,0 @@
/*
* 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.
*/
export {
notificationService,
Action,
ActionResult,
} from './service';
export { createEmailAction } from './email';
export { createSlackAction } from './slack';
export { LoggerAction } from './logger';

View file

@ -1,7 +0,0 @@
/*
* 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.
*/
export { LoggerAction } from './logger_action';

View file

@ -1,42 +0,0 @@
/*
* 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 { Action, ActionResult } from '../';
export const LOGGER_ACTION_ID = 'xpack-notifications-logger';
/**
* Logger Action enables generic logging of information into Kibana's logs.
*
* This is mostly useful for debugging.
*/
export class LoggerAction extends Action {
constructor({ server }) {
super({ server, id: LOGGER_ACTION_ID, name: 'Log' });
}
getMissingFields() {
return [];
}
async doPerformHealthCheck() {
return new ActionResult({
message: `Logger action is always usable.`,
response: { },
});
}
async doPerformAction(notification) {
this.server.log([LOGGER_ACTION_ID, 'info'], notification);
return new ActionResult({
message: 'Logged data returned as response.',
response: notification
});
}
}

View file

@ -1,50 +0,0 @@
/*
* 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 { ActionResult } from '../';
import { LOGGER_ACTION_ID, LoggerAction } from './logger_action';
describe('LoggerAction', () => {
const action = new LoggerAction({ server: { } });
test('id and name to be from constructor', () => {
expect(action.id).toBe(LOGGER_ACTION_ID);
expect(action.name).toBe('Log');
});
test('getMissingFields to return []', () => {
expect(action.getMissingFields()).toEqual([]);
});
test('doPerformHealthCheck returns ActionResult', async () => {
const result = await action.doPerformHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch('Logger action is always usable.');
expect(result.getResponse()).toEqual({ });
});
test('doPerformAction logs and returns ActionResult', async () => {
const notification = { fake: true };
const logger = jest.fn();
const server = { log: logger };
const action = new LoggerAction({ server });
const result = await action.doPerformAction(notification);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch('Logged data returned as response.');
expect(result.getResponse()).toBe(notification);
expect(logger).toHaveBeenCalledTimes(1);
expect(logger).toHaveBeenCalledWith([LOGGER_ACTION_ID, 'info'], notification);
});
});

View file

@ -1,7 +0,0 @@
/*
* 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.
*/
export { notificationServiceSendRoute } from './notify';

View file

@ -1,107 +0,0 @@
/*
* 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 { boomify } from 'boom';
/**
* Check the incoming request parameters to see if the action should be allowed to fire.
*
* @param {Object|null} action The action selected by the user.
* @param {String} actionId The ID of the requested action from the user.
* @param {Object} data The incoming data from the user.
* @returns {Object|null} The error object, or null if no error.
*/
export function checkForErrors(action, actionId, data) {
if (action === null) {
return {
message: `Unrecognized action: '${actionId}'.`,
};
} else {
let validLicense = false;
try {
validLicense = action.isLicenseValid();
} catch (e) {
// validLicense === false
}
if (validLicense === false) {
return {
message: `Unable to perform '${action.name}' action due to the current license.`,
};
}
}
const fields = action.getMissingFields(data);
if (fields.length !== 0) {
return {
message: `Unable to perform '${action.name}' action due to missing required fields.`,
fields
};
}
return null;
}
/**
* Attempt to send the {@code data} as a notification.
*
* @param {Object} server Kibana server object.
* @param {NotificationService} notificationService The notification service singleton.
* @param {String} actionId The specified action's ID.
* @param {Function} data The notification data to send via the specified action.
* @param {Function} _checkForErrors Exposed for testing.
*/
export async function sendNotification(server, notificationService, actionId, data, { _checkForErrors = checkForErrors } = { }) {
const action = notificationService.getActionForId(actionId);
const error = _checkForErrors(action, actionId, data);
if (error === null) {
return action.performAction(data)
.then(result => result.toJson())
.catch(err => boomify(err)); // by API definition, this should never happen as performAction isn't allow to throw errrors
}
server.log(['actions', 'error'], error.message);
return {
status_code: 400,
ok: false,
message: `Error: ${error.message}`,
error,
};
}
/**
* Notification Service route to perform actions (aka send data).
*/
export function notificationServiceSendRoute(server, notificationService) {
server.route({
method: 'POST',
path: '/api/notifications/v1/notify',
config: {
validate: {
payload: Joi.object({
action: Joi.string().required(),
data: Joi.object({
from: Joi.string(),
to: Joi.string(),
subject: Joi.string().required(),
markdown: Joi.string(),
}).required()
})
}
},
handler: (req) => {
const actionId = req.payload.action;
const data = req.payload.data;
sendNotification(server, notificationService, actionId, data);
},
});
}

View file

@ -1,164 +0,0 @@
/*
* 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 { checkForErrors, sendNotification } from './notify';
import { boomify } from 'boom';
describe('notifications/routes/send', () => {
const id = 'notifications-test';
const notification = { fake: true };
describe('checkForErrors', () => {
it('returns unrecognized action for null action', () => {
expect(checkForErrors(null, id, { })).toEqual({
message: `Unrecognized action: '${id}'.`,
});
});
it('returns invalid license if license check throws an error', () => {
const action = {
name: 'Test Action',
isLicenseValid: () => {
throw new Error();
},
};
expect(checkForErrors(action, id, { })).toEqual({
message: `Unable to perform '${action.name}' action due to the current license.`,
});
});
it('returns invalid license if license is invalid', () => {
const action = {
name: 'Test Action',
isLicenseValid: () => false,
};
expect(checkForErrors(action, id, { })).toEqual({
message: `Unable to perform '${action.name}' action due to the current license.`,
});
});
it('returns fields related to missing data', () => {
const fields = [ { field: 1 } ];
const action = {
name: 'Test Action',
isLicenseValid: () => true,
getMissingFields: (data) => {
expect(data).toBe(notification);
return fields;
},
};
const error = checkForErrors(action, id, notification);
expect(error).toEqual({
message: `Unable to perform '${action.name}' action due to missing required fields.`,
fields
});
});
it('returns null if action is usable', () => {
const notification = { fake: true };
const action = {
name: 'Test Action',
isLicenseValid: () => true,
getMissingFields: (data) => {
expect(data).toBe(notification);
return [];
},
};
expect(checkForErrors(action, id, notification)).toBeNull();
});
});
describe('sendNotification', () => {
it('replies with error object for bad request', async () => {
const error = {
message: 'TEST - expected',
fields: [ { fake: 1 } ],
};
const action = { };
const server = {
log: jest.fn(),
};
const notificationService = {
getActionForId: jest.fn().mockReturnValue(action),
};
const checkForErrors = jest.fn().mockReturnValue(error);
const sendResponse = await sendNotification(server, notificationService, id, notification, { _checkForErrors: checkForErrors });
expect(notificationService.getActionForId).toHaveBeenCalledTimes(1);
expect(notificationService.getActionForId).toHaveBeenCalledWith(id);
expect(checkForErrors).toHaveBeenCalledTimes(1);
expect(checkForErrors).toHaveBeenCalledWith(action, id, notification);
expect(server.log).toHaveBeenCalledTimes(1);
expect(server.log).toHaveBeenCalledWith(['actions', 'error'], error.message);
expect(sendResponse).toEqual({
status_code: 400,
ok: false,
message: `Error: ${error.message}`,
error,
});
});
it('replies with action result JSON', async () => {
const response = { ok: true, message: 'Test' };
const result = {
toJson: () => response,
};
const action = {
performAction: jest.fn().mockReturnValue(Promise.resolve(result))
};
const server = { };
const notificationService = {
getActionForId: jest.fn().mockReturnValue(action),
};
const checkForErrors = jest.fn().mockReturnValue(null);
const sendResponse = await sendNotification(server, notificationService, id, notification, { _checkForErrors: checkForErrors });
expect(notificationService.getActionForId).toHaveBeenCalledTimes(1);
expect(notificationService.getActionForId).toHaveBeenCalledWith(id);
expect(checkForErrors).toHaveBeenCalledTimes(1);
expect(checkForErrors).toHaveBeenCalledWith(action, id, notification);
expect(sendResponse).toEqual(response);
});
it('replies with unexpected result error', async () => {
const error = new Error();
const action = {
performAction: jest.fn().mockReturnValue(Promise.reject(error))
};
const server = { };
const notificationService = {
getActionForId: jest.fn().mockReturnValue(action),
};
const checkForErrors = jest.fn().mockReturnValue(null);
const sendResponse = await sendNotification(server, notificationService, id, notification, { _checkForErrors: checkForErrors });
expect(notificationService.getActionForId).toHaveBeenCalledTimes(1);
expect(notificationService.getActionForId).toHaveBeenCalledWith(id);
expect(checkForErrors).toHaveBeenCalledTimes(1);
expect(checkForErrors).toHaveBeenCalledWith(action, id, notification);
expect(sendResponse).toEqual(boomify(error));
});
});
});

View file

@ -1,575 +0,0 @@
# Notification Service / Actions
Use this service to send notifications to users. An example of a notification is sending an email or
explicitly adding something to the Kibana log.
Notifications are inherently asynchronous actions because of the likelihood that any notification is
interacting with a remote server or service.
## Referencing the Notification Service
Note: Both of these may change in the future.
### Server Side
```js
const notificationService = server.plugins.notifications.notificationService;
const action = notificationService.getActionForId('xpack-notifications-logger');
const result = action.performAction({
arbitrary: 'payload',
can: 'have multiple',
fields: [ 1, 2, 3 ]
});
```
### HTTP
```http
POST /api/notifications/v1/notify
{
"action": "xpack-notifications-logger",
"data": {
"arbitrary": "payload",
"can": "have multiple",
"fields": [ 1, 2, 3 ]
}
}
```
## Interfaces
There are two interfaces that are important from this package. `NotificationService`, which is exposed as
a singleton from the plugin: `server.plugins.notifications.notificationService`. And `Action`, which
provides an abstract JavaScript `class` to implement new `Action`s.
### NotificationService Interface
The `NotificationService` currently has four methods defined with very distinct purposes:
1. `setAction` is intended for plugin authors to add actions that do not exist with the basic notifications
service.
2. `removeAction` is intended for replacing existing plugins (e.g., augmenting another action).
3. `getActionForId` enables explicitly fetching an action by its known ID.
4. `getActionsForData` enables discovering compatible actions given an arbitrary set of data.
Note: Mutating the Notification Service should generally only be done based on very specific reasons,
such as plugin initialization or the user dynamically configuring a service's availability (e.g.,
if we support a secure data store, the user could theoretically provide authentication details for an email
server).
It is also possible that the user will want to configure multiple variants of the same action, such as
multiple email notifications with differing defaults. In that case, the action's ID may need to be
reconsidered.
#### `setAction()`
This is the only way to add an `Action` instance. Instances are expected to be extensions of the `Action`
class defined here. If the provided action already exists, then the old one is removed.
##### Syntax
```js
notificationService.setAction(action);
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `action` | Action | The unique action to support. |
###### Returns
Nothing.
##### Example
Create a simple logging action that can be triggered generically.
```js
class LoggerAction extends Action {
constructor({ server }) {
super({ server, id: 'xpack-notifications-logger', name: 'Log' });
}
getMissingFields() {
return [];
}
async doPerformHealthCheck() {
return new ActionResult({
message: `Logger action is always usable.`,
response: { },
});
}
async doPerformAction(notification) {
this.server.log(['logger', 'info'], notification);
return new ActionResult({
message: 'Logged data returned as response.',
response: notification
});
}
}
// It's possible that someone may choose to make the LoggerAction's log level configurable, so
// replacing it could be done by re-setting it, which means any follow-on usage would use the new level
// (or, possibly, you could create different actions entirely for different levels)
notificationService.setAction(new LoggerAction({ server }));
```
#### `removeAction()`
Remove an action that has been set.
##### Syntax
```js
const action = notificationService.removeAction(actionId);
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `id` | String | ID of the action to remove. |
###### Returns
| Type | Description |
|------|-------------|
| Action \| null | The action that was removed. `null` otherwise. |
##### Example
```js
const action = notificationService.removeAction('xpack-notifications-logger');
if (action !== null) {
// removed; otherwise it didn't exist (maybe it was already removed)
}
```
#### `getActionForId()`
Retrieve a specific `Action` from the Notification Service.
##### Syntax
```js
const action = notificationService.getActionForId(actionId);
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `id` | String | ID of the action to retrieve. |
###### Returns
| Type | Description |
|------|-------------|
| Action \| null | The action that was requested. `null` otherwise. |
##### Example
```js
// In this case, the ID is known from the earlier example
const action = notificationService.getActionForId('xpack-notifications-logger');
if (action !== null) {
// otherwise it didn't exist
}
```
#### `getActionsForData()`
Retrieve any `Action`s from the Notification Service that accept the supplied data, which is useful for
discovery.
##### Syntax
```js
const actions = notificationService.getActionsForData(notification);
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `notification` | Object | Payload to send notification. |
###### Returns
| Type | Description |
|------|-------------|
| Action[] | The actions that accept a subset of the data. Empty array otherwise. |
##### Example
```js
// In this case, the ID is known from the earlier example
const actions = notificationService.getActionsForData({
arbitrary: 'payload',
can: 'have multiple',
fields: [ 1, 2, 3 ]
});
if (action.length !== 0) {
// otherwise nothing matches
}
```
### Action Interface
From the perspective of developers that want to make use of `Action`s, there are three relevant methods:
1. `getMissingFields` provides an array of fields as well as the expected data type that did not exist in the
supplied data.
2. `performHealthCheck` attempts to perform a health check against the actions backing service.
3. `performAction` attempts to perform the purpose of the action (e.g., send the email) using the supplied
data.
For developers to create new `Action`s, there are three related methods:
1. `getMissingFields` provides an array of fields as well as the expected data type that did not exist in the
supplied data.
2. `doPerformHealthCheck` attempts to perform a health check against the actions backing service.
- `performHealthCheck` invokes this method and wraps it in order to catch `Error`s.
3. `doPerformAction` attempts to perform the purpose of the action (e.g., send the email) using the supplied
data.
- `performAction` invokes this method and wraps it in order to catch `Error`s.
Every method, excluding `getMissingFields`, is asynchronous.
#### `getMissingFields()`
This method enables the building of "Sharing"-style UIs that allow the same payload to be shared across
many different actions. This is the same approach taken in iOS and Android sharing frameworks.
##### Syntax
```js
action.getMissingFields({
arbitrary: 'payload',
can: 'have multiple',
fields: [ 1, 2, 3 ]
});
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `notification` | Object | The data that you want to try to use with the action. |
###### Returns
| Type | Description |
|------|-------------|
| Object[] | The fields that were not present in the `notification` object. Empty array otherwise. |
The object definition should match:
| Field | Type | Description |
|-------|------|-------------|
| `field` | String | The JSON field name that was expected. |
| `name` | String | The user-readable name of the field (e.g., for a generated UI). |
| `type` | String | The type of data (`email`, `text`, `markdown`, `number`, `date`, `boolean`). |
NOTE: This method _never_ throws an `Error`.
##### Example
Create a simple action whose fields can be checked automatically.
```js
// Action Users
const action = notificationService.getActionForId(actionId);
const fields = action.getMissingFields({
arbitrary: 'payload',
can: 'have multiple',
fields: [ 1, 2, 3 ]
});
if (fields.length !== 0) {
// there's some fields missing so this action should not be used yet
}
// Action Creators
class FakeAction extends Action {
constructor({ server, defaults = { } }) {
super({ server, id: 'xpack-notifications-fake', name: 'Fake' });
this.defaults = defaults;
}
getMissingFields(notification) {
const missingFields = [];
if (!Boolean(this.defaults.to) && !Boolean(notification.to)) {
missingFields.push({
field: 'to',
name: 'To',
type: 'email',
});
}
return missingFields;
}
// ... other methods ...
}
```
#### `performHealthCheck()`
This method enables the health status of third party services to be polled. The current approach only allows
an `Action`'s health to be in a boolean state: either it's up and expected to work, or it's down.
The health check is interesting because some third party services that we anticipate supporting are
inherently untrustworthy when it comes to supporting health checks (e.g., email servers). Therefore, it
should not be expected that the health check will block usage of actions -- only provide feedback to the
user, which we can ideally provide in a future notification center within the UI.
##### Syntax
```js
const result = await action.performHealthCheck();
```
###### Parameters
None.
###### Returns
| Type | Description |
|------|-------------|
| ActionResult | The result of the health check. |
An `ActionResult` defines a few methods:
| Method | Type | Description |
|-------|------|-------------|
| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". |
| `getError()` | Object \| undefined | JSON error object. |
| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. |
| `getMessage()` | String | Human readable message describing the state. |
NOTE: This method _never_ throws an `Error`.
##### Example
Create a simple action whose fields can be checked automatically.
```js
const action = notificationService.getActionForId(actionId);
const result = await action.performHealthCheck();
if (result.isOk()) {
// theoretically the action is in a usable state
} else {
// theoretically the action is not in a usable state (but some services may have broken health checks!)
}
```
#### `performAction()`
This method enables the actual usage of the `Action` for action users.
##### Syntax
```js
const result = await action.performAction({
arbitrary: 'payload',
can: 'have multiple',
fields: [ 1, 2, 3 ]
});
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `notification` | Object | The data that you want to try to use with the action. |
###### Returns
| Type | Description |
|------|-------------|
| ActionResult | The result of the health check. |
An `ActionResult` defines a few methods:
| Method | Type | Description |
|-------|------|-------------|
| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". |
| `getError()` | Object \| undefined | JSON error object. |
| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. |
| `getMessage()` | String | Human readable message describing the state. |
NOTE: This method _never_ throws an `Error`.
##### Example
Create a simple action whose fields can be checked automatically.
```js
const action = notificationService.getActionForId(actionId);
const result = await action.performAction({
arbitrary: 'payload',
can: 'have multiple',
fields: [ 1, 2, 3 ]
});
if (result.isOk()) {
// theoretically the action is in a usable state
} else {
// theoretically the action is not in a usable state (but some services may have broken health checks!)
}
```
#### `doPerformHealthCheck()`
This method is for `Action` creators. This performs the actual work to check the health of the action's
associated service as best as possible.
This method should be thought of as a `protected` method only and it should never be called directly
outside of tests.
##### Syntax
Do not call this method directly outside of tests.
```js
const result = await action.doPerformHealthCheck();
```
###### Parameters
None.
###### Returns
| Type | Description |
|------|-------------|
| ActionResult | The result of the health check. |
An `ActionResult` defines a few methods:
| Method | Type | Description |
|-------|------|-------------|
| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". |
| `getError()` | Object \| undefined | JSON error object. |
| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. |
| `getMessage()` | String | Human readable message describing the state. |
NOTE: This method can throw an `Error` in lieu of returning an `ActionResult`.
##### Example
Create a simple action whose health status can be checked automatically.
```js
class FakeAction extends Action {
constructor({ server }) {
super({ server, id: 'xpack-notifications-fake', name: 'Fake' });
}
async doPerformHealthCheck() {
// this responds with a boolean 'true' response, otherwise throws an Error
const response = await this.transporter.verify();
return new ActionResult({
message: `Fake action configuration has been verified.`,
response: {
verified: true
},
});
}
// ... other methods ...
}
```
#### `doPerformAction()`
This method is for `Action` creators. This performs the actual function of the action.
This method should be thought of as a `protected` method only and it should never be called directly
outside of tests.
##### Syntax
Do not call this method directly outside of tests.
```js
const result = await action.doPerformAction();
```
###### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `notification` | Object | The data that you want to try to use with the action. |
###### Returns
| Type | Description |
|------|-------------|
| ActionResult | The result of the health check. |
An `ActionResult` defines a few methods:
| Method | Type | Description |
|-------|------|-------------|
| `isOk()` | Boolean | `true` if there was no error and it is believed to be "up". |
| `getError()` | Object \| undefined | JSON error object. |
| `getResponse()` | Object \| undefined | JSON response from the server. If the server returns a field, it should be wrapped. |
| `getMessage()` | String | Human readable message describing the state. |
NOTE: This method can throw an `Error` in lieu of returning an `ActionResult`.
##### Example
Create a simple action whose health status can be checked automatically.
```js
class FakeAction extends Action {
constructor({ server }) {
super({ server, id: 'xpack-notifications-fake', name: 'Fake' });
}
async doPerformAction(notification) {
// Note: This throws an Error upon failure
const response = await this.transporter.sendMail({
from: notification.from,
to: notification.to,
cc: notification.cc,
bcc: notification.bcc,
subject: notification.subject,
html: notification.markdown,
text: notification.markdown,
});
return new ActionResult({
message: `Success! Sent email for '${notification.subject}'.`,
response,
});
}
// ... other methods ...
}
```

View file

@ -1,143 +0,0 @@
/*
* 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 { ActionResult } from './action_result';
/**
* Actions represent a singular, generic "action", such as "Send to Email".
*
* Note: Implementations of Action are inherently server-side operations. It may or may not be desiable to fire
* these actions from the UI (triggering a server-side call), but these should be called for such a purpose.
*/
export class Action {
/**
* Create a new Action.
*
* The suggested ID is the name of the plugin that provides it, and the unique portion.
* For example: "core-email" if core provided email.
*
* @param {Object} server The Kibana server object.
* @param {String} id The unique identifier for the action.
* @param {String} name User-friendly name for the action.
*/
constructor({ server, id, name }) {
this.server = server;
this.id = id;
this.name = name;
}
/**
* Get the unique ID of the Action.
*
* @return {String}
*/
getId() {
return this.id;
}
/**
* Get the user-friendly name of the Action.
*
* @return {String}
*/
getName() {
return this.name;
}
/**
* Determine if this action can use the {@code notification}. This is useful if you use the action service
* generically, such as using it from a generic UI.
*
* This is intended to be a simple check of the {@code notification}, rather than an asynchronous action.
*
* @param {Object} notification The notification data to use.
* @return {Array} Array defining missing fields. Empty if none.
*/
getMissingFields() {
return [];
}
/**
* Implementers must override to perform the health check.
*
* This should not be called directly outside of tests to ensure that any error is wrapped properly.
*
* Note: Some services do not provide consistent, reliable health checks, such as email. As such,
* implementers must weigh the nature of false negatives versus the utility of having this check.
*
* @return {Promise} The result of the health check, which must be an {@code ActionResult}.
* @throws {Error} if there is an unexpected failure occurs.
*/
async doPerformHealthCheck() {
throw new Error(`[doPerformHealthCheck] is not implemented for '${this.name}' action.`);
}
/**
* Verify that the action can be used to the best of the ability of the service that it is using.
*
* @return {Promise} Always an {@code ActionResult}.
*/
async performHealthCheck() {
try {
return await this.doPerformHealthCheck();
} catch (error) {
return new ActionResult({
message: `Unable to perform '${this.name}' health check: ${error.message}.`,
error
});
}
}
/**
* Implementers must override to perform the action using the {@code notification}.
*
* This should not be called directly to ensure that any error is wrapped properly.
*
* @param {Object} notification The notification data to use.
* @return {Promise} The result of the action, which must be a {@code ActionResult}.
* @throws {Error} if the method is not implemented or an unexpected failure occurs.
*/
async doPerformAction(notification) {
throw new Error(`[doPerformAction] is not implemented for '${this.name}' action: ${JSON.stringify(notification)}`);
}
/**
* Check to see if the current license allows actions.
*
* @return {Boolean} true when it is usable
* @throws {Error} if there is an unexpected issue checking the license
*/
isLicenseValid() {
return this.server.plugins.xpack_main.info.license.isNotBasic();
}
/**
* Perform the action using the {@code notification}.
*
* Actions automatically fail if the license check fails.
*
* Note to implementers: override doPerformAction instead of this method to help guarantee proper handling.
*
* @param {Object} notification The notification data to use.
* @return {Promise} The result of the action, which must be a {@code ActionResult}.
*/
async performAction(notification) {
try {
if (!this.isLicenseValid()) {
throw new Error(`The current license does not allow '${this.name}' action.`);
}
return await this.doPerformAction(notification);
} catch (error) {
return new ActionResult({
message: `Unable to perform '${this.name}' action: ${error.message}.`,
error
});
}
}
}

View file

@ -1,242 +0,0 @@
/*
* 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 { Action } from './action';
import { ActionResult } from './action_result';
describe('Action', () => {
const server = { };
const id = 'notifications-test';
const unimplementedName = 'Unimplemented';
const throwsErrorName = 'Throws Error';
const passThruName = 'Test Action';
const action = new Action({ server, id, name: unimplementedName });
const notification = {
fake: true,
};
test('id and name to be from constructor', () => {
expect(action.server).toBe(server);
expect(action.getId()).toBe(id);
expect(action.getName()).toBe(unimplementedName);
});
test('getMissingFields returns an empty array', () => {
expect(action.getMissingFields()).toEqual([]);
expect(action.getMissingFields(notification)).toEqual([]);
});
test('doPerformHealthChecks throws error indicating that it is not implemented', async () => {
await expect(action.doPerformHealthCheck())
.rejects
.toThrow(`[doPerformHealthCheck] is not implemented for '${unimplementedName}' action.`);
});
describe('performHealthChecks', () => {
class ThrowsErrorHealthCheckAction extends Action {
constructor() {
super({ server: { }, id, name: throwsErrorName });
}
async doPerformHealthCheck() {
throw new Error('TEST - expected');
}
}
class PassThruHealthCheckAction extends Action {
constructor(result) {
super({ server: { }, id, name: passThruName });
this.result = result;
}
async doPerformHealthCheck() {
return this.result;
}
}
test('runs against unimplemented doPerformHealthChecks', async () => {
const result = await action.performHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage())
.toMatch(new RegExp(`^Unable to perform '${unimplementedName}' health check: \\[doPerformHealthCheck\\] is not.*`));
});
test('runs against failing doPerformHealthChecks', async () => {
const failAction = new ThrowsErrorHealthCheckAction();
const result = await failAction.performHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage())
.toMatch(new RegExp(`^Unable to perform '${throwsErrorName}' health check: TEST - expected`));
});
test('runs against succeeding result', async () => {
const expectedResult = new ActionResult({ message: 'Blah', response: { ok: true } });
const succeedsAction = new PassThruHealthCheckAction(expectedResult);
const result = await succeedsAction.performHealthCheck();
expect(result).toBe(expectedResult);
});
});
test('doPerformAction throws error indicating that it is not implemented', async () => {
await expect(action.doPerformAction(notification))
.rejects
.toThrow(`[doPerformAction] is not implemented for '${unimplementedName}' action: {"fake":true}`);
});
describe('isLicenseValid', () => {
test('server variable is not exposed as expected', () => {
expect(() => action.isLicenseValid()).toThrow(Error);
});
test('returns false is license is not valid', () => {
const unlicensedServer = {
plugins: {
xpack_main: {
info: {
license: {
isNotBasic: () => false
}
}
}
}
};
const unlicensedAction = new Action({ server: unlicensedServer, id, name: unimplementedName });
expect(unlicensedAction.isLicenseValid()).toBe(false);
});
test('returns true is license is not valid', () => {
const licensedServer = {
plugins: {
xpack_main: {
info: {
license: {
isNotBasic: () => true
}
}
}
}
};
const licensedAction = new Action({ server: licensedServer, id, name: unimplementedName });
expect(licensedAction.isLicenseValid()).toBe(true);
});
});
describe('performAction', () => {
// valid license
const server = {
plugins: {
xpack_main: {
info: {
license: {
isNotBasic: () => true
}
}
}
}
};
class ThrowsErrorAction extends Action {
constructor() {
super({ server, id, name: throwsErrorName });
}
async doPerformAction() {
throw new Error('TEST - expected');
}
}
class PassThruAction extends Action {
constructor(result) {
super({ server, id, name: passThruName });
this.result = result;
}
async doPerformAction() {
return this.result;
}
}
describe('fails license check', () => {
// handles the case when xpack_main definitions change
test('because of bad reference', async () => {
// server is an empty object, so a reference fails early in the chain (mostly a way to give devs a way to find this error)
const result = await action.performAction(notification);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
});
test('because license is invalid or basic', async () => {
const unlicensedServer = {
plugins: {
xpack_main: {
info: {
license: {
isNotBasic: () => false
}
}
}
}
};
const unlicensedAction = new Action({ server: unlicensedServer, id, name: unimplementedName });
const result = await unlicensedAction.performAction(notification);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage())
.toMatch(
`Unable to perform '${unimplementedName}' action: ` +
`The current license does not allow '${unimplementedName}' action.`
);
});
});
test('runs against unimplemented doPerformAction', async () => {
const licensedAction = new Action({ server, id, name: unimplementedName });
const result = await licensedAction.performAction(notification);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage())
.toMatch(new RegExp(`^Unable to perform '${unimplementedName}' action: \\[doPerformAction\\] is not.*`));
});
test('runs against failing doPerformAction', async () => {
const failAction = new ThrowsErrorAction();
const result = await failAction.performAction(notification);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage())
.toMatch(new RegExp(`^Unable to perform '${throwsErrorName}' action: TEST - expected`));
});
test('runs against succeeding result', async () => {
const expectedResult = new ActionResult({ message: 'Blah', response: { ok: true } });
const succeedsAction = new PassThruAction(expectedResult);
const result = await succeedsAction.performAction(notification);
expect(result).toBe(expectedResult);
});
});
});

View file

@ -1,73 +0,0 @@
/*
* 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.
*/
/**
* Action Results represent generic, predictable responses from Actions.
*/
export class ActionResult {
/**
* Create a new Action Result.
*
* Success is determined by the existence of an error.
*
* @param {String} message The message to display about the result, presumably in a Toast.
* @param {Object|undefined} response The response from the "other" side.
* @param {Object|undefined} error The error, if any.
*/
constructor({ message, response, error }) {
this.message = message;
this.response = response;
this.error = error;
this.ok = !Boolean(error);
}
/**
* Get the error caused by the action.
*
* @returns {Object|undefined} The error response, or {@code undefined} if no error.
*/
getError() {
return this.error;
}
/**
* Get the message displayable to the user.
*
* @returns {String} The message.
*/
getMessage() {
return this.message;
}
/**
* The raw JSON response from the action.
*
* @returns {Object|undefined} The JSON response.
*/
getResponse() {
return this.response;
}
/**
* Determine if the action succeeded.
*
* @returns {Boolean} {@code true} for success.
*/
isOk() {
return this.ok;
}
toJson() {
return {
ok: this.ok,
error: this.error,
message: this.message,
response: this.response,
};
}
}

View file

@ -1,54 +0,0 @@
/*
* 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 { ActionResult } from './action_result';
describe('ActionResult', () => {
const message = 'this is a message';
const response = { other: { side: { response: true } } };
const error = { message: `Error: ${message}` };
const okResult = new ActionResult({ message, response });
const notOkResult = new ActionResult({ message, response, error });
test('getError returns supplied error or undefined', () => {
expect(okResult.getError()).toBeUndefined();
expect(notOkResult.getError()).toBe(error);
});
test('getMessage returns supplied message', () => {
expect(okResult.getMessage()).toBe(message);
expect(notOkResult.getMessage()).toBe(message);
});
test('getResponse returns supplied response', () => {
expect(okResult.getResponse()).toBe(response);
expect(notOkResult.getResponse()).toBe(response);
});
test('isOk returns based on having an error', () => {
expect(okResult.isOk()).toBe(true);
expect(notOkResult.isOk()).toBe(false);
});
test('toJson', () => {
expect(okResult.toJson()).toEqual({
ok: true,
error: undefined,
message,
response,
});
expect(notOkResult.toJson()).toEqual({
ok: false,
error,
message,
response,
});
});
});

View file

@ -1,9 +0,0 @@
/*
* 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.
*/
export { notificationService } from './notification_service';
export { Action } from './action';
export { ActionResult } from './action_result';

View file

@ -1,86 +0,0 @@
/*
* 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.
*/
/**
* Notification Service represents a service that contains generic "actions", such as "Send to Email"
* that are added at startup by plugins to enable notifications to be sent either by the user manually
* or via the some scheduled / automated task.
*/
export class NotificationService {
constructor() {
this.actions = [];
}
/**
* Add a new action to the action service.
*
* @param {Action} action An implementation of Action.
*/
setAction = (action) => {
this.removeAction(action.id);
this.actions.push(action);
}
/**
* Remove an existing action from the action service.
*
* @param {String} id The ID of the action to remove.
* @return {Action} The action that was removed, or null.
*/
removeAction = (id) => {
const index = this.actions.findIndex(action => action.id === id);
if (index !== -1) {
const removedActions = this.actions.splice(index, 1);
return removedActions[0];
}
return null;
}
/**
* Get action with the specified {@code id}, if any.
*
* This is useful when you know that an action is provided, such as one provided by your own plugin,
* and you want to use it to handle things in a consistent way.
*
* @param {String} id The ID of the Action.
* @return {Action} The Action that matches the ID, or null.
*/
getActionForId = (id) => {
const index = this.actions.findIndex(action => action.id === id);
if (index !== -1) {
return this.actions[index];
}
return null;
}
/**
* Get actions that will accept the {@code data}.
*
* @param {Object} data The data object to pass to actions.
* @return {Array} An array of Actions that can be used with the data, if any. Empty if none.
*/
getActionsForData = (data) => {
return this.actions.filter(action => {
try {
return action.getMissingFields(data).length === 0;
} catch (err) {
return false;
}
});
}
}
/**
* A singleton reference to the notification service intended to be used across Kibana.
*/
export const notificationService = new NotificationService();

View file

@ -1,134 +0,0 @@
/*
* 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 { Action } from './action';
import { NotificationService } from './notification_service';
class TestAction extends Action {
constructor({ server, id }) {
super({ server, id, name: 'TestAction' });
}
getMissingFields() {
return [];
}
}
// always returns a missing field
class MissingFieldTestAction extends Action {
constructor({ server, id }) {
super({ server, id, name: 'MissingFieldTestAction' });
}
getMissingFields() {
return [ { field: 'subject', name: 'Subject', type: 'text' } ];
}
}
describe('NotificationService', () => {
const server = { };
const actionId = 'notifications-test';
const action = new TestAction({ server, id: actionId });
let notificationService;
beforeEach(() => {
notificationService = new NotificationService();
});
test('initializes with no default actions', () => {
expect(notificationService.actions).toEqual([]);
});
describe('setAction', () => {
test('adds the action', () => {
notificationService.setAction(action);
expect(notificationService.actions[0]).toBe(action);
});
test('removes any action with the same ID first, then adds the action', () => {
notificationService.setAction({ id: actionId });
notificationService.setAction(action);
expect(notificationService.actions).toHaveLength(1);
expect(notificationService.actions[0]).toBe(action);
});
});
describe('removeAction', () => {
test('returns null if the action does not exist', () => {
expect(notificationService.removeAction(actionId)).toBe(null);
notificationService.setAction(action);
expect(notificationService.removeAction('not-' + actionId)).toBe(null);
expect(notificationService.actions[0]).toBe(action);
});
test('returns the removed action', () => {
notificationService.setAction(action);
expect(notificationService.removeAction(actionId)).toBe(action);
expect(notificationService.actions).toEqual([]);
});
});
describe('getActionForId', () => {
test('returns null if the action does not exist', () => {
expect(notificationService.getActionForId(actionId)).toBe(null);
notificationService.setAction(action);
expect(notificationService.getActionForId('not-' + actionId)).toBe(null);
expect(notificationService.actions[0]).toBe(action);
});
test('returns the action', () => {
notificationService.setAction(action);
expect(notificationService.getActionForId(actionId)).toBe(action);
expect(notificationService.actions[0]).toBe(action);
});
});
describe('getActionsForData', () => {
test('returns [] if no corresponding action exists', () => {
expect(notificationService.getActionsForData({})).toEqual([]);
notificationService.setAction(new MissingFieldTestAction({ server, id: 'always-missing' }));
expect(notificationService.getActionsForData({})).toEqual([]);
expect(notificationService.actions).toHaveLength(1);
});
test('returns the actions that match', () => {
notificationService.setAction(action);
expect(notificationService.getActionsForData({})).toEqual([ action ]);
expect(notificationService.actions[0]).toBe(action);
const otherActionId = 'other-' + actionId;
notificationService.setAction(new MissingFieldTestAction({ server, id: 'always-missing' }));
notificationService.setAction(new TestAction({ server, id: otherActionId }));
const actions = notificationService.getActionsForData({});
expect(actions.map(action => action.id)).toEqual([ actionId, otherActionId ]);
});
});
});

View file

@ -1,56 +0,0 @@
/*
* 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 { SlackAction } from './slack_action';
/**
* Create a Slack options object from the config.
*
* @param {Object} config The server configuration.
* @return {Object} An object that configures Slack.
*/
export function optionsFromConfig(config) {
return {
token: config.get('xpack.notifications.slack.token')
};
}
/**
* Create a Slack defaults object from the config.
*
* Defaults include things like the default channel that messages are posted to.
*
* @param {Object} config The server configuration.
* @return {Object} An object that configures Slack on a per-message basis.
*/
export function defaultsFromConfig(config) {
return {
channel: config.get('xpack.notifications.slack.defaults.channel'),
as_user: config.get('xpack.notifications.slack.defaults.as_user'),
icon_emoji: config.get('xpack.notifications.slack.defaults.icon_emoji'),
icon_url: config.get('xpack.notifications.slack.defaults.icon_url'),
link_names: config.get('xpack.notifications.slack.defaults.link_names'),
mrkdwn: config.get('xpack.notifications.slack.defaults.mrkdwn'),
unfurl_links: config.get('xpack.notifications.slack.defaults.unfurl_links'),
unfurl_media: config.get('xpack.notifications.slack.defaults.unfurl_media'),
username: config.get('xpack.notifications.slack.defaults.username'),
};
}
/**
* Create a new Slack Action based on the configuration.
*
* @param {Object} server The server object.
* @return {SlackAction} A new Slack Action based on the kibana.yml configuration.
*/
export function createSlackAction(server, { _options = optionsFromConfig, _defaults = defaultsFromConfig } = { }) {
const config = server.config();
const options = _options(config);
const defaults = _defaults(config);
return new SlackAction({ server, options, defaults });
}

View file

@ -1,94 +0,0 @@
/*
* 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 { SlackAction } from './slack_action';
import {
createSlackAction,
defaultsFromConfig,
optionsFromConfig,
} from './create_slack_action';
describe('create_slack_action', () => {
test('optionsFromConfig uses config without modification', () => {
const get = key => {
const suffixes = [
'token',
];
const value = suffixes.find(suffix => {
return `xpack.notifications.slack.${suffix}` === key;
});
if (value === undefined) {
throw new Error(`Unknown config key used ${key}`);
}
return value;
};
expect(optionsFromConfig({ get })).toEqual({
token: 'token',
});
});
test('defaultsFromConfig uses config without modification', () => {
const get = key => {
const suffixes = [
'channel',
'as_user',
'icon_emoji',
'icon_url',
'link_names',
'mrkdwn',
'unfurl_links',
'unfurl_media',
'username',
];
const value = suffixes.find(suffix => {
return `xpack.notifications.slack.defaults.${suffix}` === key;
});
if (value === undefined) {
throw new Error(`Unknown config key used ${key}`);
}
return value;
};
expect(defaultsFromConfig({ get })).toEqual({
channel: 'channel',
as_user: 'as_user',
icon_emoji: 'icon_emoji',
icon_url: 'icon_url',
link_names: 'link_names',
mrkdwn: 'mrkdwn',
unfurl_links: 'unfurl_links',
unfurl_media: 'unfurl_media',
username: 'username',
});
});
test('createSlackAction', async () => {
const config = { };
const server = { config: jest.fn().mockReturnValue(config) };
const _options = jest.fn().mockReturnValue({ options: true });
const defaults = { defaults: true };
const _defaults = jest.fn().mockReturnValue(defaults);
const action = createSlackAction(server, { _options, _defaults });
expect(action instanceof SlackAction).toBe(true);
expect(action.defaults).toBe(defaults);
expect(server.config).toHaveBeenCalledTimes(1);
expect(server.config).toHaveBeenCalledWith();
expect(_options).toHaveBeenCalledTimes(1);
expect(_options).toHaveBeenCalledWith(config);
expect(_defaults).toHaveBeenCalledTimes(1);
expect(_defaults).toHaveBeenCalledWith(config);
});
});

View file

@ -1,7 +0,0 @@
/*
* 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.
*/
export { createSlackAction } from './create_slack_action';

View file

@ -1,116 +0,0 @@
/*
* 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 { WebClient } from '@slack/client';
import { Action, ActionResult } from '../';
export const SLACK_ACTION_ID = 'xpack-notifications-slack';
/**
* Create a new Slack {@code WebClient}.
*
* Currently the only option expected is {@code token}.
*
* @param {Object} options Slack API options.
* @returns {WebClient} Always.
*/
export function webClientCreator(options) {
return new WebClient(options.token);
}
/**
* Slack Action enables generic sending of Slack messages.
*/
export class SlackAction extends Action {
/**
* Create a new Action capable of sending Slack messages.
*
* @param {Object} server Kibana server object.
* @param {Object} options Configuration options for the Slack WebClient. Currently only expect "token" field.
* @param {Object} defaults Default fields used when sending messages.
* @param {Function} _webClientCreator Exposed for tests.
*/
constructor({ server, options, defaults = { }, _webClientCreator = webClientCreator }) {
super({ server, id: SLACK_ACTION_ID, name: 'Slack' });
this.client = _webClientCreator(options);
this.defaults = defaults;
}
getMissingFields(data) {
const missingFields = [];
if (!Boolean(this.defaults.channel) && !Boolean(data.channel)) {
missingFields.push({
field: 'channel',
name: 'Channel',
type: 'text',
});
}
if (!Boolean(data.subject)) {
missingFields.push({
field: 'subject',
name: 'Message',
type: 'markdown',
});
}
return missingFields;
}
async doPerformHealthCheck() {
const response = await this.client.api.test();
if (response.ok) {
return new ActionResult({
message: `Slack action configuration has been verified.`,
response,
});
}
return new ActionResult({
message: `Slack action configuration could not be verified.`,
response,
error: response.error || { message: 'Unknown Error' },
});
}
/**
* Render the message based on whether or not a {@code markdown} body was supplied.
*/
renderMessage({ subject, markdown }) {
const attachments = [];
if (markdown) {
attachments.push({ text: markdown });
}
return { text: subject, attachments };
}
async doPerformAction({ subject, markdown, channel }) {
// NOTE: When we want to support files, then we should look into using client.files.upload({ ... })
// without _also_ sending chat message because the file upload endpoint supports chat behavior
// in addition to files, but the reverse is not true.
const slackChannel = channel || this.defaults.channel;
const response = await this.client.chat.postMessage({
...this.defaults,
...this.renderMessage({ subject, markdown }),
channel: slackChannel,
});
return new ActionResult({
message: `Posted Slack message to channel '${slackChannel}'.`,
response,
error: response.error,
});
}
}

View file

@ -1,277 +0,0 @@
/*
* 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 { WebClient } from '@slack/client';
import { ActionResult } from '../';
import {
SLACK_ACTION_ID,
SlackAction,
webClientCreator,
} from './slack_action';
describe('SlackAction', () => {
const server = { };
const options = { options: true };
const defaults = { defaults: true };
const client = {
api: {
// see beforeEach
},
chat: {
// see beforeEach
}
};
let _webClientCreator;
let action;
beforeEach(() => {
client.api.test = jest.fn();
client.chat.postMessage = jest.fn();
_webClientCreator = jest.fn().mockReturnValue(client);
action = new SlackAction({ server, options, defaults, _webClientCreator });
});
test('webClientCreator creates a WebClient', () => {
expect(webClientCreator('faketoken') instanceof WebClient).toBe(true);
});
test('id and name to be from constructor', () => {
expect(action.getId()).toBe(SLACK_ACTION_ID);
expect(action.getName()).toBe('Slack');
expect(action.client).toBe(client);
expect(_webClientCreator).toHaveBeenCalledTimes(1);
expect(_webClientCreator).toHaveBeenCalledWith(options);
});
describe('getMissingFields', () => {
test('returns missing fields', () => {
const channel = { field: 'channel', name: 'Channel', type: 'text' };
const subject = { field: 'subject', name: 'Message', type: 'markdown' };
const missing = [
{ defaults: { }, notification: { }, missing: [ channel, subject, ], },
{ defaults: { }, notification: { channel: '#kibana', }, missing: [ subject, ], },
{ defaults: { channel: '#kibana', }, notification: { }, missing: [ subject, ], },
{ defaults: { }, notification: { subject: 'subject', }, missing: [ channel, ], },
];
missing.forEach(check => {
const newDefaultsAction = new SlackAction({ server, options, defaults: check.defaults, _webClientCreator });
expect(newDefaultsAction.getMissingFields(check.notification)).toEqual(check.missing);
});
});
test('returns [] when all fields exist', () => {
const exists = [
{ defaults: { }, notification: { channel: '#kibana', subject: 'subject', }, },
{ defaults: { channel: '#kibana', }, notification: { subject: 'subject', }, },
];
exists.forEach(check => {
const newDefaultsAction = new SlackAction({ server, options, defaults: check.defaults, _webClientCreator });
expect(newDefaultsAction.getMissingFields(check.notification)).toEqual([]);
});
});
});
describe('doPerformHealthCheck', () => {
test('rethrows Error for failure', async () => {
const error = new Error('TEST - expected');
client.api.test.mockRejectedValue(error);
await expect(action.doPerformHealthCheck())
.rejects
.toThrow(error);
expect(client.api.test).toHaveBeenCalledTimes(1);
expect(client.api.test).toHaveBeenCalledWith();
});
test('returns ActionResult if not ok with error', async () => {
const response = { ok: false, error: { expected: true } };
client.api.test.mockResolvedValue(response);
const result = await action.doPerformHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage()).toMatch('Slack action configuration could not be verified.');
expect(result.getResponse()).toBe(response);
expect(result.getError()).toBe(response.error);
expect(client.api.test).toHaveBeenCalledTimes(1);
expect(client.api.test).toHaveBeenCalledWith();
});
test('returns ActionResult if not ok with default error', async () => {
const response = { ok: false };
client.api.test.mockResolvedValue(response);
const result = await action.doPerformHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage()).toMatch('Slack action configuration could not be verified.');
expect(result.getResponse()).toBe(response);
expect(result.getError()).toEqual({ message: 'Unknown Error' });
expect(client.api.test).toHaveBeenCalledTimes(1);
expect(client.api.test).toHaveBeenCalledWith();
});
test('returns ActionResult for success', async () => {
const response = { ok: true };
client.api.test.mockResolvedValue(response);
const result = await action.doPerformHealthCheck();
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch('Slack action configuration has been verified.');
expect(result.getResponse()).toBe(response);
expect(client.api.test).toHaveBeenCalledTimes(1);
expect(client.api.test).toHaveBeenCalledWith();
});
});
describe('renderMessage', () => {
test('does not contain attachments', () => {
const message = { subject: 'subject' };
const response = action.renderMessage(message);
expect(response).toMatchObject({
text: message.subject,
attachments: [ ]
});
});
test('contains attachments', () => {
const message = { subject: 'subject', markdown: 'markdown' };
const response = action.renderMessage(message);
expect(response).toMatchObject({
text: message.subject,
attachments: [
{
text: message.markdown
}
]
});
});
});
describe('doPerformAction', () => {
const message = { channel: '#kibana', subject: 'subject', markdown: 'body', };
test('rethrows Error for failure', async () => {
const error = new Error('TEST - expected');
client.chat.postMessage.mockRejectedValue(error);
await expect(action.doPerformAction(message))
.rejects
.toThrow(error);
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(client.chat.postMessage).toHaveBeenCalledWith({
...defaults,
...action.renderMessage(message),
channel: message.channel,
});
});
test('returns ActionResult for failure without Error', async () => {
const response = { fake: true, error: { expected: true } };
client.chat.postMessage.mockResolvedValue(response);
const result = await action.doPerformAction(message);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(false);
expect(result.getMessage()).toMatch(`Posted Slack message to channel '${message.channel}'.`);
expect(result.getResponse()).toBe(response);
expect(result.getError()).toBe(response.error);
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(client.chat.postMessage).toHaveBeenCalledWith({
...defaults,
...action.renderMessage(message),
channel: message.channel,
});
});
test('returns ActionResult for success', async () => {
const response = { fake: true };
client.chat.postMessage.mockResolvedValue(response);
const result = await action.doPerformAction(message);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch(`Posted Slack message to channel '${message.channel}'.`);
expect(result.getResponse()).toBe(response);
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(client.chat.postMessage).toHaveBeenCalledWith({
...defaults,
...action.renderMessage(message),
channel: message.channel,
});
});
test('returns ActionResult for success with default channel', async () => {
const response = { fake: false };
client.chat.postMessage.mockResolvedValue(response);
const channelDefaults = {
...defaults,
channel: '#kibana',
};
const noChannelMessage = {
...message,
channel: undefined,
};
const newDefaultsAction = new SlackAction({ server, options, defaults: channelDefaults, _webClientCreator });
const result = await newDefaultsAction.doPerformAction(noChannelMessage);
expect(result instanceof ActionResult).toBe(true);
expect(result.isOk()).toBe(true);
expect(result.getMessage()).toMatch(`Posted Slack message to channel '${channelDefaults.channel}'.`);
expect(result.getResponse()).toBe(response);
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(client.chat.postMessage).toHaveBeenCalledWith({
...defaults,
...action.renderMessage(noChannelMessage),
channel: channelDefaults.channel,
});
});
});
});