mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Alerting] Adds a builtin action for triggering webhooks (#43538)
Adds the ability to trigger webhooks using an action. This feature is currently locked off while we figure out the right privileges model.
This commit is contained in:
parent
6a9844c223
commit
e8c50c0cfb
12 changed files with 745 additions and 19 deletions
|
@ -10,19 +10,17 @@ import nodemailerServices from 'nodemailer/lib/well-known/services.json';
|
|||
|
||||
import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email';
|
||||
import { nullableType } from './lib/nullable';
|
||||
import { portSchema } from './lib/schemas';
|
||||
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
|
||||
|
||||
const PORT_MAX = 256 * 256 - 1;
|
||||
|
||||
// config definition
|
||||
|
||||
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const ConfigSchema = schema.object(
|
||||
{
|
||||
service: nullableType(schema.string()),
|
||||
host: nullableType(schema.string()),
|
||||
port: nullableType(schema.number({ min: 1, max: PORT_MAX })),
|
||||
port: nullableType(portSchema()),
|
||||
secure: nullableType(schema.boolean()),
|
||||
from: schema.string(),
|
||||
},
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 { fromNullable, Option } from 'fp-ts/lib/Option';
|
||||
|
||||
export function getRetryAfterIntervalFromHeaders(headers: Record<string, string>): Option<number> {
|
||||
return fromNullable(headers['retry-after'])
|
||||
.map(retryAfter => parseInt(retryAfter, 10))
|
||||
.filter(retryAfter => !isNaN(retryAfter));
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// There appears to be an unexported implementation of Either in here: src/core/server/saved_objects/service/lib/repository.ts
|
||||
// Which is basically the Haskel equivalent of Rust/ML/Scala's Result
|
||||
// I'll reach out to other's in Kibana to see if we can merge these into one type
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-interface
|
||||
export type Ok<T> = {
|
||||
tag: 'ok';
|
||||
value: T;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-interface
|
||||
export type Err<E> = {
|
||||
tag: 'err';
|
||||
error: E;
|
||||
};
|
||||
export type Result<T, E> = Ok<T> | Err<E>;
|
||||
|
||||
export function asOk<T>(value: T): Ok<T> {
|
||||
return {
|
||||
tag: 'ok',
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function asErr<T>(error: T): Err<T> {
|
||||
return {
|
||||
tag: 'err',
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
||||
return result.tag === 'ok';
|
||||
}
|
||||
|
||||
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
||||
return !isOk(result);
|
||||
}
|
||||
|
||||
export async function promiseResult<T, E>(future: Promise<T>): Promise<Result<T, E>> {
|
||||
try {
|
||||
return asOk(await future);
|
||||
} catch (e) {
|
||||
return asErr(e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
|
||||
const PORT_MAX = 256 * 256 - 1;
|
||||
export const portSchema = () => schema.number({ min: 1, max: PORT_MAX });
|
|
@ -7,6 +7,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook';
|
||||
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
|
@ -81,13 +82,9 @@ async function slackExecutor(
|
|||
|
||||
// special handling for rate limiting
|
||||
if (status === 429) {
|
||||
const retryAfterString = headers['retry-after'];
|
||||
if (retryAfterString != null) {
|
||||
const retryAfter = parseInt(retryAfterString, 10);
|
||||
if (!isNaN(retryAfter)) {
|
||||
return retryResultSeconds(id, err.message, retryAfter);
|
||||
}
|
||||
}
|
||||
return getRetryAfterIntervalFromHeaders(headers)
|
||||
.map(retry => retryResultSeconds(id, err.message, retry))
|
||||
.getOrElse(retryResult(id, err.message));
|
||||
}
|
||||
|
||||
return errorResult(id, `${err.message} - ${statusText}`);
|
||||
|
@ -154,7 +151,7 @@ function retryResult(id: string, message: string): ActionTypeExecutorResult {
|
|||
function retryResultSeconds(
|
||||
id: string,
|
||||
message: string,
|
||||
retryAfter: number = 60
|
||||
retryAfter: number
|
||||
): ActionTypeExecutorResult {
|
||||
const retryEpoch = Date.now() + retryAfter * 1000;
|
||||
const retry = new Date(retryEpoch);
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { actionType } from './webhook';
|
||||
import { validateConfig, validateSecrets, validateParams } from '../lib';
|
||||
|
||||
describe('actionType', () => {
|
||||
test('exposes the action as `webhook` on its Id and Name', () => {
|
||||
expect(actionType.id).toEqual('.webhook');
|
||||
expect(actionType.name).toEqual('webhook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secrets validation', () => {
|
||||
test('succeeds when secrets is valid', () => {
|
||||
const secrets: Record<string, any> = {
|
||||
user: 'bob',
|
||||
password: 'supersecret',
|
||||
};
|
||||
expect(validateSecrets(actionType, secrets)).toEqual(secrets);
|
||||
});
|
||||
|
||||
test('fails when secret password is omitted', () => {
|
||||
expect(() => {
|
||||
validateSecrets(actionType, { user: 'bob' });
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
});
|
||||
|
||||
test('fails when secret user is omitted', () => {
|
||||
expect(() => {
|
||||
validateSecrets(actionType, {});
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config validation', () => {
|
||||
const defaultValues: Record<string, any> = {
|
||||
headers: null,
|
||||
method: 'post',
|
||||
};
|
||||
|
||||
test('config validation passes when only required fields are provided', () => {
|
||||
const config: Record<string, any> = {
|
||||
url: 'http://mylisteningserver:9200/endpoint',
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...defaultValues,
|
||||
...config,
|
||||
});
|
||||
});
|
||||
|
||||
test('config validation passes when valid methods are provided', () => {
|
||||
['post', 'put'].forEach(method => {
|
||||
const config: Record<string, any> = {
|
||||
url: 'http://mylisteningserver:9200/endpoint',
|
||||
method,
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...defaultValues,
|
||||
...config,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate and throw error when method on config is invalid', () => {
|
||||
const config: Record<string, any> = {
|
||||
url: 'http://mylisteningserver:9200/endpoint',
|
||||
method: 'https',
|
||||
};
|
||||
expect(() => {
|
||||
validateConfig(actionType, config);
|
||||
}).toThrowErrorMatchingInlineSnapshot(`
|
||||
"error validating action type config: [method]: types that failed validation:
|
||||
- [method.0]: expected value to equal [post] but got [https]
|
||||
- [method.1]: expected value to equal [put] but got [https]"
|
||||
`);
|
||||
});
|
||||
|
||||
test('config validation passes when a url is specified', () => {
|
||||
const config: Record<string, any> = {
|
||||
url: 'http://mylisteningserver:9200/endpoint',
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...defaultValues,
|
||||
...config,
|
||||
});
|
||||
});
|
||||
|
||||
test('config validation passes when valid headers are provided', () => {
|
||||
const config: Record<string, any> = {
|
||||
url: 'http://mylisteningserver:9200/endpoint',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
expect(validateConfig(actionType, config)).toEqual({
|
||||
...defaultValues,
|
||||
...config,
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate and throw error when headers on config is invalid', () => {
|
||||
const config: Record<string, any> = {
|
||||
url: 'http://mylisteningserver:9200/endpoint',
|
||||
headers: 'application/json',
|
||||
};
|
||||
expect(() => {
|
||||
validateConfig(actionType, config);
|
||||
}).toThrowErrorMatchingInlineSnapshot(`
|
||||
"error validating action type config: [headers]: types that failed validation:
|
||||
- [headers.0]: expected value of type [object] but got [string]
|
||||
- [headers.1]: expected value to equal [null] but got [application/json]"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('params validation', () => {
|
||||
test('param validation passes when no fields are provided as none are required', () => {
|
||||
const params: Record<string, any> = {};
|
||||
expect(validateParams(actionType, params)).toEqual({});
|
||||
});
|
||||
|
||||
test('params validation passes when a valid body is provided', () => {
|
||||
const params: Record<string, any> = {
|
||||
body: 'count: {{ctx.payload.hits.total}}',
|
||||
};
|
||||
expect(validateParams(actionType, params)).toEqual({
|
||||
...params,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
|
||||
import { nullableType } from './lib/nullable';
|
||||
import { isOk, promiseResult, Result } from './lib/result_type';
|
||||
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
|
||||
|
||||
// config definition
|
||||
enum WebhookMethods {
|
||||
POST = 'post',
|
||||
PUT = 'put',
|
||||
}
|
||||
|
||||
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
|
||||
|
||||
const HeadersSchema = schema.recordOf(schema.string(), schema.string());
|
||||
|
||||
const ConfigSchema = schema.object({
|
||||
url: schema.string(),
|
||||
method: schema.oneOf([schema.literal(WebhookMethods.POST), schema.literal(WebhookMethods.PUT)], {
|
||||
defaultValue: WebhookMethods.POST,
|
||||
}),
|
||||
headers: nullableType(HeadersSchema),
|
||||
});
|
||||
|
||||
// secrets definition
|
||||
export type ActionTypeSecretsType = TypeOf<typeof SecretsSchema>;
|
||||
const SecretsSchema = schema.object({
|
||||
user: schema.string(),
|
||||
password: schema.string(),
|
||||
});
|
||||
|
||||
// params definition
|
||||
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
|
||||
const ParamsSchema = schema.object({
|
||||
body: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
// action type definition
|
||||
export const actionType: ActionType = {
|
||||
id: '.webhook',
|
||||
name: 'webhook',
|
||||
validate: {
|
||||
config: ConfigSchema,
|
||||
secrets: SecretsSchema,
|
||||
params: ParamsSchema,
|
||||
},
|
||||
executor,
|
||||
};
|
||||
|
||||
// action executor
|
||||
async function executor(execOptions: ActionTypeExecutorOptions): Promise<ActionTypeExecutorResult> {
|
||||
const log = (level: string, msg: string) =>
|
||||
execOptions.services.log([level, 'actions', 'webhook'], msg);
|
||||
|
||||
const id = execOptions.id;
|
||||
const { method, url, headers = {} } = execOptions.config as ActionTypeConfigType;
|
||||
const { user: username, password } = execOptions.secrets as ActionTypeSecretsType;
|
||||
const { body: data } = execOptions.params as ActionParamsType;
|
||||
|
||||
const result: Result<AxiosResponse, AxiosError> = await promiseResult(
|
||||
axios.request({
|
||||
method,
|
||||
url,
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
headers,
|
||||
data,
|
||||
})
|
||||
);
|
||||
|
||||
if (isOk(result)) {
|
||||
const {
|
||||
value: { status, statusText },
|
||||
} = result;
|
||||
log('debug', `response from ${id} webhook event: [HTTP ${status}] ${statusText}`);
|
||||
|
||||
return successResult(data);
|
||||
} else {
|
||||
const { error } = result;
|
||||
|
||||
if (error.response) {
|
||||
const { status, statusText, headers: responseHeaders } = error.response;
|
||||
const message = `[${status}] ${statusText}`;
|
||||
log(`warn`, `error on ${id} webhook event: ${message}`);
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
// special handling for 5xx
|
||||
if (status >= 500) {
|
||||
return retryResult(id, message);
|
||||
}
|
||||
|
||||
// special handling for rate limiting
|
||||
if (status === 429) {
|
||||
return getRetryAfterIntervalFromHeaders(responseHeaders)
|
||||
.map(retry => retryResultSeconds(id, message, retry))
|
||||
.getOrElse(retryResult(id, message));
|
||||
}
|
||||
return errorResultInvalid(id, message);
|
||||
}
|
||||
|
||||
const message = i18n.translate('xpack.actions.builtin.webhook.unreachableRemoteWebhook', {
|
||||
defaultMessage: 'Unreachable Remote Webhook, are you sure the address is correct?',
|
||||
});
|
||||
log(`warn`, `error on ${id} webhook event: ${message}`);
|
||||
return errorResultUnreachable(id, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Action Executor Result w/ internationalisation
|
||||
function successResult(data: any): ActionTypeExecutorResult {
|
||||
return { status: 'ok', data };
|
||||
}
|
||||
|
||||
function errorResultInvalid(id: string, message: string): ActionTypeExecutorResult {
|
||||
const errMessage = i18n.translate('xpack.actions.builtin.webhook.invalidResponseErrorMessage', {
|
||||
defaultMessage: 'an error occurred in action "{id}" calling a remote webhook: {message}',
|
||||
values: {
|
||||
id,
|
||||
message,
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: 'error',
|
||||
message: errMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function errorResultUnreachable(id: string, message: string): ActionTypeExecutorResult {
|
||||
const errMessage = i18n.translate('xpack.actions.builtin.webhook.unreachableErrorMessage', {
|
||||
defaultMessage: 'an error occurred in action "{id}" calling a remote webhook: {message}',
|
||||
values: {
|
||||
id,
|
||||
message,
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: 'error',
|
||||
message: errMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function retryResult(id: string, message: string): ActionTypeExecutorResult {
|
||||
const errMessage = i18n.translate(
|
||||
'xpack.actions.builtin.webhook.invalidResponseRetryLaterErrorMessage',
|
||||
{
|
||||
defaultMessage: 'an error occurred in action "{id}" calling a remote webhook, retry later',
|
||||
values: {
|
||||
id,
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
status: 'error',
|
||||
message: errMessage,
|
||||
retry: true,
|
||||
};
|
||||
}
|
||||
|
||||
function retryResultSeconds(
|
||||
id: string,
|
||||
message: string,
|
||||
|
||||
retryAfter: number
|
||||
): ActionTypeExecutorResult {
|
||||
const retryEpoch = Date.now() + retryAfter * 1000;
|
||||
const retry = new Date(retryEpoch);
|
||||
const retryString = retry.toISOString();
|
||||
const errMessage = i18n.translate(
|
||||
'xpack.actions.builtin.webhook.invalidResponseRetryDateErrorMessage',
|
||||
{
|
||||
defaultMessage:
|
||||
'an error occurred in action "{id}" calling a remote webhook, retry at {retryString}: {message}',
|
||||
values: {
|
||||
id,
|
||||
retryString,
|
||||
message,
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
status: 'error',
|
||||
message: errMessage,
|
||||
retry,
|
||||
};
|
||||
}
|
|
@ -8,10 +8,7 @@ import path from 'path';
|
|||
import { CA_CERT_PATH } from '@kbn/dev-utils';
|
||||
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
|
||||
import { services } from './services';
|
||||
import {
|
||||
getExternalServiceSimulatorPath,
|
||||
ExternalServiceSimulator,
|
||||
} from './fixtures/plugins/actions';
|
||||
import { getAllExternalServiceSimulatorPaths } from './fixtures/plugins/actions';
|
||||
|
||||
interface CreateTestConfigOptions {
|
||||
license: string;
|
||||
|
@ -59,9 +56,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
|
|||
...disabledPlugins.map(key => `--xpack.${key}.enabled=false`),
|
||||
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`,
|
||||
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'actions')}`,
|
||||
`--server.xsrf.whitelist=${JSON.stringify([
|
||||
getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK),
|
||||
])}`,
|
||||
`--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`,
|
||||
...(ssl
|
||||
? [
|
||||
`--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`,
|
||||
|
|
|
@ -5,17 +5,25 @@
|
|||
*/
|
||||
import Hapi from 'hapi';
|
||||
import { initPlugin as initSlack } from './slack_simulation';
|
||||
import { initPlugin as initWebhook } from './webhook_simulation';
|
||||
|
||||
const NAME = 'actions-FTS-external-service-simulators';
|
||||
|
||||
export enum ExternalServiceSimulator {
|
||||
SLACK = 'slack',
|
||||
WEBHOOK = 'webhook',
|
||||
}
|
||||
|
||||
export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string {
|
||||
return `/api/_${NAME}/${service}`;
|
||||
}
|
||||
|
||||
export function getAllExternalServiceSimulatorPaths(): string[] {
|
||||
return Object.values(ExternalServiceSimulator).map(service =>
|
||||
getExternalServiceSimulatorPath(service)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function(kibana: any) {
|
||||
return new kibana.Plugin({
|
||||
|
@ -23,6 +31,7 @@ export default function(kibana: any) {
|
|||
name: NAME,
|
||||
init: (server: Hapi.Server) => {
|
||||
initSlack(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK));
|
||||
initWebhook(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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 Joi from 'joi';
|
||||
import Hapi, { Util } from 'hapi';
|
||||
import { fromNullable } from 'fp-ts/lib/Option';
|
||||
|
||||
interface WebhookRequest extends Hapi.Request {
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export async function initPlugin(server: Hapi.Server, path: string) {
|
||||
server.auth.scheme('identifyCredentialsIfPresent', function identifyCredentialsIfPresent(
|
||||
s: Hapi.Server,
|
||||
options?: Hapi.ServerAuthSchemeOptions
|
||||
) {
|
||||
const scheme = {
|
||||
async authenticate(request: Hapi.Request, h: Hapi.ResponseToolkit) {
|
||||
const credentials = fromNullable(request.headers.authorization)
|
||||
.map(authorization => authorization.split(/\s+/))
|
||||
.filter(parts => parts.length > 1)
|
||||
.map(parts => Buffer.from(parts[1], 'base64').toString())
|
||||
.filter(credentialsPart => credentialsPart.indexOf(':') !== -1)
|
||||
.map(credentialsPart => {
|
||||
const [username, password] = credentialsPart.split(':');
|
||||
return { username, password };
|
||||
})
|
||||
.getOrElse({ username: '', password: '' });
|
||||
|
||||
return h.authenticated({ credentials });
|
||||
},
|
||||
};
|
||||
|
||||
return scheme;
|
||||
});
|
||||
server.auth.strategy('simple', 'identifyCredentialsIfPresent');
|
||||
|
||||
server.route({
|
||||
method: ['POST', 'PUT'],
|
||||
path,
|
||||
options: {
|
||||
auth: 'simple',
|
||||
validate: {
|
||||
options: { abortEarly: false },
|
||||
payload: Joi.string(),
|
||||
},
|
||||
},
|
||||
handler: webhookHandler,
|
||||
});
|
||||
}
|
||||
|
||||
function webhookHandler(request: WebhookRequest, h: any) {
|
||||
const body = request.payload;
|
||||
|
||||
switch (body) {
|
||||
case 'success':
|
||||
return htmlResponse(h, 200, `OK`);
|
||||
case 'authenticate':
|
||||
return validateAuthentication(request, h);
|
||||
case 'success_post_method':
|
||||
return validateRequestUsesMethod(request, h, 'post');
|
||||
case 'success_put_method':
|
||||
return validateRequestUsesMethod(request, h, 'put');
|
||||
case 'faliure':
|
||||
return htmlResponse(h, 500, `Error`);
|
||||
}
|
||||
|
||||
return htmlResponse(
|
||||
h,
|
||||
400,
|
||||
`unknown request to webhook simulator [${body ? `content: ${body}` : `no content`}]`
|
||||
);
|
||||
}
|
||||
|
||||
function validateAuthentication(request: WebhookRequest, h: any) {
|
||||
const {
|
||||
auth: { credentials },
|
||||
} = request;
|
||||
try {
|
||||
expect(credentials).to.eql({
|
||||
username: 'elastic',
|
||||
password: 'changeme',
|
||||
});
|
||||
return htmlResponse(h, 200, `OK`);
|
||||
} catch (ex) {
|
||||
return htmlResponse(h, 403, `the validateAuthentication operation failed. ${ex.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequestUsesMethod(
|
||||
request: WebhookRequest,
|
||||
h: any,
|
||||
method: Util.HTTP_METHODS_PARTIAL
|
||||
) {
|
||||
try {
|
||||
expect(request.method).to.eql(method);
|
||||
return htmlResponse(h, 200, `OK`);
|
||||
} catch (ex) {
|
||||
return htmlResponse(h, 403, `the validateAuthentication operation failed. ${ex.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function htmlResponse(h: any, code: number, text: string) {
|
||||
return h
|
||||
.response(text)
|
||||
.type('text/html')
|
||||
.code(code);
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* 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 { URL, format as formatUrl } from 'url';
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import {
|
||||
getExternalServiceSimulatorPath,
|
||||
ExternalServiceSimulator,
|
||||
} from '../../../../common/fixtures/plugins/actions';
|
||||
|
||||
const defaultValues: Record<string, any> = {
|
||||
headers: null,
|
||||
method: 'post',
|
||||
};
|
||||
|
||||
function parsePort(url: Record<string, string>): Record<string, string | null | number> {
|
||||
return {
|
||||
...url,
|
||||
port: url.port ? parseInt(url.port, 10) : url.port,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function webhookTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
async function createWebhookAction(
|
||||
urlWithCreds: string,
|
||||
config: Record<string, string | Record<string, string>> = {}
|
||||
): Promise<string> {
|
||||
const { url: fullUrl, user, password } = extractCredentialsFromUrl(urlWithCreds);
|
||||
const url = config.url && typeof config.url === 'object' ? parsePort(config.url) : fullUrl;
|
||||
const composedConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
...config,
|
||||
url,
|
||||
};
|
||||
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
description: 'A generic Webhook action',
|
||||
actionTypeId: '.webhook',
|
||||
secrets: {
|
||||
user,
|
||||
password,
|
||||
},
|
||||
config: composedConfig,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
return createdAction.id;
|
||||
}
|
||||
|
||||
describe('webhook action', () => {
|
||||
let webhookSimulatorURL: string = '<could not determine kibana url>';
|
||||
|
||||
// need to wait for kibanaServer to settle ...
|
||||
before(() => {
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const kibanaUrl = kibanaServer.status && kibanaServer.status.kibanaServerUrl;
|
||||
const webhookServiceUrl = getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK);
|
||||
webhookSimulatorURL = `${kibanaUrl}${webhookServiceUrl}`;
|
||||
});
|
||||
|
||||
after(() => esArchiver.unload('empty_kibana'));
|
||||
|
||||
it('should return 200 when creating a webhook action successfully', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
description: 'A generic Webhook action',
|
||||
actionTypeId: '.webhook',
|
||||
secrets: {
|
||||
user: 'username',
|
||||
password: 'mypassphrase',
|
||||
},
|
||||
config: {
|
||||
url: webhookSimulatorURL,
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
description: 'A generic Webhook action',
|
||||
actionTypeId: '.webhook',
|
||||
config: {
|
||||
...defaultValues,
|
||||
url: webhookSimulatorURL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(typeof createdAction.id).to.be('string');
|
||||
|
||||
const { body: fetchedAction } = await supertest
|
||||
.get(`/api/action/${createdAction.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(fetchedAction).to.eql({
|
||||
id: fetchedAction.id,
|
||||
description: 'A generic Webhook action',
|
||||
actionTypeId: '.webhook',
|
||||
config: {
|
||||
...defaultValues,
|
||||
url: webhookSimulatorURL,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send authentication to the webhook target', async () => {
|
||||
const webhookActionId = await createWebhookAction(webhookSimulatorURL);
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${webhookActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
params: {
|
||||
body: 'authenticate',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.eql('ok');
|
||||
});
|
||||
|
||||
it('should support the POST method against webhook target', async () => {
|
||||
const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'post' });
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${webhookActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
params: {
|
||||
body: 'success_post_method',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.eql('ok');
|
||||
});
|
||||
|
||||
it('should support the PUT method against webhook target', async () => {
|
||||
const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'put' });
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${webhookActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
params: {
|
||||
body: 'success_put_method',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.eql('ok');
|
||||
});
|
||||
|
||||
it('should handle unreachable webhook targets', async () => {
|
||||
const webhookActionId = await createWebhookAction('http://some.non.existent.com/endpoint');
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${webhookActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
params: {
|
||||
body: 'failure',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.eql('error');
|
||||
expect(result.message).to.match(/Unreachable Remote Webhook/);
|
||||
});
|
||||
|
||||
it('should handle failing webhook targets', async () => {
|
||||
const webhookActionId = await createWebhookAction(webhookSimulatorURL);
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${webhookActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
params: {
|
||||
body: 'failure',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.eql('error');
|
||||
expect(result.message).to.match(/Bad Request/);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractCredentialsFromUrl(url: string): { url: string; user: string; password: string } {
|
||||
const parsedUrl = new URL(url);
|
||||
const { password, username: user } = parsedUrl;
|
||||
return { url: formatUrl(parsedUrl, { auth: false }), user, password };
|
||||
}
|
7
x-pack/test/typings/hapi_basic.d.ts
vendored
Normal file
7
x-pack/test/typings/hapi_basic.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
declare module '@hapi/basic';
|
Loading…
Add table
Add a link
Reference in a new issue