From ada32662a50fde4e48ab3e7662c5e147440e3891 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 22 Aug 2019 10:46:04 -0400 Subject: [PATCH] Adds a built-in PagerDuty action The PagerDuty action can be used to post events via the PagerDuty Events API v2: https://v2.developer.pagerduty.com/docs/events-api-v2 Some slight refactoring of the action service simulators, to get the xsrf paths set up so the FT config.ts doesn't have to be updated every time we add a simulator. --- .../server/builtin_action_types/index.ts | 2 + .../lib/post_pagerduty.ts | 26 ++ .../builtin_action_types/pagerduty.test.ts | 419 ++++++++++++++++++ .../server/builtin_action_types/pagerduty.ts | 209 +++++++++ .../alerting_api_integration/common/config.ts | 9 +- .../common/fixtures/plugins/actions/README.md | 73 ++- .../common/fixtures/plugins/actions/index.ts | 10 + .../plugins/actions/pagerduty_simulation.ts | 79 ++++ .../actions/builtin_action_types/pagerduty.ts | 185 ++++++++ .../tests/actions/index.ts | 1 + 10 files changed, 958 insertions(+), 55 deletions(-) create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/pagerduty_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/pagerduty.ts diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts index ea0597933618461..b717436c5477777 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts @@ -10,10 +10,12 @@ import { actionType as serverLogActionType } from './server_log'; import { actionType as slackActionType } from './slack'; import { actionType as emailActionType } from './email'; import { actionType as indexActionType } from './es_index'; +import { actionType as pagerDutyActionType } from './pagerduty'; export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { actionTypeRegistry.register(serverLogActionType); actionTypeRegistry.register(slackActionType); actionTypeRegistry.register(emailActionType); actionTypeRegistry.register(indexActionType); + actionTypeRegistry.register(pagerDutyActionType); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts new file mode 100644 index 000000000000000..cc9d36ff86342c3 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts @@ -0,0 +1,26 @@ +/* + * 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 axios, { AxiosResponse } from 'axios'; +import { Services } from '../../types'; + +interface PostPagerdutyOptions { + apiUrl: string; + data: any; + headers: Record; + services: Services; +} + +// post an event to pagerduty +export async function postPagerduty(options: PostPagerdutyOptions): Promise { + const { apiUrl, data, headers } = options; + const axiosOptions = { + headers, + validateStatus: () => true, + }; + + return axios.post(apiUrl, data, axiosOptions); +} diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts new file mode 100644 index 000000000000000..842603d4ce8293a --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -0,0 +1,419 @@ +/* + * 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. + */ + +jest.mock('./lib/post_pagerduty', () => ({ + postPagerduty: jest.fn(), +})); + +import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { taskManagerMock } from '../../../task_manager/task_manager.mock'; +import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; +import { validateConfig, validateSecrets, validateParams } from '../lib'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { postPagerduty } from './lib/post_pagerduty'; +import { registerBuiltInActionTypes } from './index'; + +const postPagerdutyMock = postPagerduty as jest.Mock; + +const ACTION_TYPE_ID = '.pagerduty'; +const NO_OP_FN = () => {}; + +const services: Services = { + log: NO_OP_FN, + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: SavedObjectsClientMock.create(), +}; + +function getServices(): Services { + return services; +} + +let actionType: ActionType; +let actionTypeRegistry: ActionTypeRegistry; + +const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); + +beforeAll(() => { + actionTypeRegistry = new ActionTypeRegistry({ + getServices, + isSecurityEnabled: true, + taskManager: taskManagerMock.create(), + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }); + registerBuiltInActionTypes(actionTypeRegistry); + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +beforeEach(() => { + services.log = NO_OP_FN; +}); + +describe('action registation', () => { + test('should be successful', () => { + expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true); + }); +}); + +describe('get()', () => { + test('should return correct action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('pagerduty'); + }); +}); + +describe('validateConfig()', () => { + test('should validate and pass when config is valid', () => { + expect(validateConfig(actionType, {})).toEqual({ apiUrl: null }); + expect(validateConfig(actionType, { apiUrl: 'bar' })).toEqual({ apiUrl: 'bar' }); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateConfig(actionType, { shouldNotBeHere: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [shouldNotBeHere]: definition for this key is missing"` + ); + }); +}); + +describe('validateSecrets()', () => { + test('should validate and pass when secrets is valid', () => { + const routingKey = 'super-secret'; + expect(validateSecrets(actionType, { routingKey })).toEqual({ + routingKey, + }); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(actionType, { routingKey: false }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [routingKey]: expected value of type [string] but got [boolean]"` + ); + + expect(() => { + validateSecrets(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [routingKey]: expected value of type [string] but got [undefined]"` + ); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + expect(validateParams(actionType, {})).toEqual({}); + + const params = { + eventAction: 'trigger', + dedupKey: 'a dedupKey', + summary: 'a summary', + source: 'a source', + severity: 'critical', + timestamp: new Date().toISOString(), + component: 'a component', + group: 'a group', + class: 'a class', + }; + expect(validateParams(actionType, params)).toEqual(params); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, { eventAction: 'ackynollage' }); + }).toThrowErrorMatchingInlineSnapshot(` +"error validating action params: [eventAction]: types that failed validation: +- [eventAction.0]: expected value to equal [trigger] but got [ackynollage] +- [eventAction.1]: expected value to equal [resolve] but got [ackynollage] +- [eventAction.2]: expected value to equal [acknowledge] but got [ackynollage]" +`); + }); +}); + +describe('execute()', () => { + beforeEach(() => { + postPagerdutyMock.mockReset(); + }); + + test('should succeed with minimal valid params', async () => { + const secrets = { routingKey: 'super-secret' }; + const config = {}; + const params = {}; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "https://events.pagerduty.com/v2/enqueue", + "data": Object { + "dedup_key": "action:some-action-id", + "event_action": "trigger", + "payload": Object { + "severity": "info", + "source": "Kibana Action some-action-id", + "summary": "No summary provided.", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should succeed with maximal valid params for trigger', async () => { + const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: randoDate, + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + "timestamp": "1963-09-23T01:23:45.000Z", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should succeed with maximal valid params for acknowledge', async () => { + const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params = { + eventAction: 'acknowledge', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: randoDate, + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "acknowledge", + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should succeed with maximal valid params for resolve', async () => { + const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params = { + eventAction: 'resolve', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: randoDate, + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "resolve", + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should fail when sendPagerdury throws', async () => { + const secrets = { routingKey: 'super-secret' }; + const config = {}; + const params = {}; + + postPagerdutyMock.mockImplementation(() => { + throw new Error('doing some testing'); + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "message": "error in pagerduty action \\"some-action-id\\" posting event: doing some testing", + "status": "error", + } + `); + }); + + test('should fail when sendPagerdury returns 429', async () => { + const secrets = { routingKey: 'super-secret' }; + const config = {}; + const params = {}; + + postPagerdutyMock.mockImplementation(() => { + return { status: 429, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "message": "error in pagerduty action \\"some-action-id\\" posting event: status 429, retry later", + "retry": true, + "status": "error", + } + `); + }); + + test('should fail when sendPagerdury returns 501', async () => { + const secrets = { routingKey: 'super-secret' }; + const config = {}; + const params = {}; + + postPagerdutyMock.mockImplementation(() => { + return { status: 501, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "message": "error in pagerduty action \\"some-action-id\\" posting event: status 501, retry later", + "retry": true, + "status": "error", + } + `); + }); + + test('should fail when sendPagerdury returns 418', async () => { + const secrets = { routingKey: 'super-secret' }; + const config = {}; + const params = {}; + + postPagerdutyMock.mockImplementation(() => { + return { status: 418, data: 'data-here' }; + }); + + const id = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "message": "error in pagerduty action \\"some-action-id\\" posting event: unexpected status 418", + "status": "error", + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts new file mode 100644 index 000000000000000..1739770edfb836c --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -0,0 +1,209 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { postPagerduty } from './lib/post_pagerduty'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; + +// uses the PagerDuty Events API v2 +// https://v2.developer.pagerduty.com/docs/events-api-v2 +const PAGER_DUTY_API_URL = 'https://events.pagerduty.com/v2/enqueue'; + +// config definition + +export type ActionTypeConfigType = TypeOf; + +const ConfigSchema = schema.object({ + apiUrl: schema.nullable(schema.string()), +}); + +// secrets definition + +export type ActionTypeSecretsType = TypeOf; + +const SecretsSchema = schema.object({ + routingKey: schema.string(), +}); + +// params definition + +export type ActionParamsType = TypeOf; + +const EVENT_ACTION_TRIGGER = 'trigger'; +const EVENT_ACTION_RESOLVE = 'resolve'; +const EVENT_ACTION_ACKNOWLEDGE = 'acknowledge'; + +const EventActionSchema = schema.oneOf([ + schema.literal(EVENT_ACTION_TRIGGER), + schema.literal(EVENT_ACTION_RESOLVE), + schema.literal(EVENT_ACTION_ACKNOWLEDGE), +]); + +const PayloadSeveritySchema = schema.oneOf([ + schema.literal('critical'), + schema.literal('error'), + schema.literal('warning'), + schema.literal('info'), +]); + +const ParamsSchema = schema.object( + { + eventAction: schema.maybe(EventActionSchema), + dedupKey: schema.maybe(schema.string({ maxLength: 255 })), + summary: schema.maybe(schema.string({ maxLength: 1024 })), + source: schema.maybe(schema.string()), + severity: schema.maybe(PayloadSeveritySchema), + timestamp: schema.maybe(schema.string()), + component: schema.maybe(schema.string()), + group: schema.maybe(schema.string()), + class: schema.maybe(schema.string()), + }, + { validate: validateParams } +); + +function validateParams(paramsObject: any): string | void { + const params: ActionParamsType = paramsObject; + + const { timestamp } = params; + if (timestamp != null) { + let date; + try { + date = Date.parse(timestamp); + } catch (err) { + return 'error parsing timestamp: ${err.message}'; + } + + if (isNaN(date)) { + return 'error parsing timestamp'; + } + } +} + +// action type definition + +export const actionType: ActionType = { + id: '.pagerduty', + name: 'pagerduty', + validate: { + config: ConfigSchema, + secrets: SecretsSchema, + params: ParamsSchema, + }, + executor, +}; + +// action executor + +async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; + const config = execOptions.config as ActionTypeConfigType; + const secrets = execOptions.secrets as ActionTypeSecretsType; + const params = execOptions.params as ActionParamsType; + const services = execOptions.services; + + const apiUrl = config.apiUrl || PAGER_DUTY_API_URL; + const headers = { + 'Content-Type': 'application/json', + 'X-Routing-Key': secrets.routingKey, + }; + const data = getBodyForEventAction(id, params); + + let response; + try { + response = await postPagerduty({ apiUrl, data, headers, services }); + } catch (err) { + const message = i18n.translate('xpack.actions.builtin.pagerduty.postingErrorMessage', { + defaultMessage: 'error in pagerduty action "{id}" posting event: {errorMessage}', + values: { + id, + errorMessage: err.message, + }, + }); + services.log( + ['warn', 'actions', 'pagerduty'], + `error thrown posting pagerduty event: ${err.message}` + ); + return { + status: 'error', + message, + }; + } + + services.log( + ['debug', 'actions', 'pagerduty'], + `response posting pagerduty event: ${response.status}` + ); + + if (response.status === 202) { + return { + status: 'ok', + data: response.data, + }; + } + + if (response.status === 429 || response.status >= 500) { + const message = i18n.translate('xpack.actions.builtin.pagerduty.postingRetryErrorMessage', { + defaultMessage: + 'error in pagerduty action "{id}" posting event: status {status}, retry later', + values: { + id, + status: response.status, + }, + }); + + return { + status: 'error', + message, + retry: true, + }; + } + + const message = i18n.translate('xpack.actions.builtin.pagerduty.postingUnexpectedErrorMessage', { + defaultMessage: 'error in pagerduty action "{id}" posting event: unexpected status {status}', + values: { + id, + status: response.status, + }, + }); + + return { + status: 'error', + message, + }; +} + +// utilities + +const AcknowledgeOrResolve = new Set([EVENT_ACTION_ACKNOWLEDGE, EVENT_ACTION_RESOLVE]); + +function getBodyForEventAction(actionId: string, params: ActionParamsType): any { + const eventAction = params.eventAction || EVENT_ACTION_TRIGGER; + const dedupKey = params.dedupKey || `action:${actionId}`; + + const data: any = { + event_action: eventAction, + dedup_key: dedupKey, + }; + + // for acknowledge / resolve, just send the dedup key + if (AcknowledgeOrResolve.has(eventAction)) { + return data; + } + + data.payload = { + summary: params.summary || 'No summary provided.', + source: params.source || `Kibana Action ${actionId}`, + severity: params.severity || 'info', + }; + + if (params.timestamp != null) data.payload.timestamp = params.timestamp; + if (params.component != null) data.payload.component = params.component; + if (params.group != null) data.payload.group = params.group; + if (params.class != null) data.payload.class = params.class; + + return data; +} diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index c9f19a0ef453804..ccc17da22d099d2 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -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}`, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/README.md b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/README.md index 0a4c7addba327e1..c248bdce3785d58 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/README.md +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/README.md @@ -1,56 +1,46 @@ functional test server slack simulator ================================================================================ -The code in this directory will run a Slack HTTP simulator; it will return -different responses based on the content of the text message sent to the -endpoint. +The code in this directory includes external service simulators for testing +Kibana actions. The simulators are available when running the function test +server. -This will be used during functional testing runner tests for actions; an -action will be created pointing to the simulator, and then messages posted -to test handling different error conditions. +They are used during function testing for actions; an action will be created +pointing to the simulator, and then messages posted to test handling different +error conditions. +Generally, the simulator will generate specialized http responses based on +some string property passed as input. Consult the simulators for more details. -what a Slack server returns +simulator usage -------------------------------------------------------------------------------- -Here's some examples of `curl`'ing a Slack webhook to see the different -responses it will return: +This may get out of date, consult the code for exact urls and inputs. Each +simulator's last path segment should be the name of the service (eg, slack, +pagerduty, etc). ```console -$ curl -v $SLACK_WEBHOOK_URL -d '{"text":"Hello, World!"}' -< HTTP/2 200 -< content-type: text/html -ok - -$ curl -v $SLACK_WEBHOOK_URL -d '{"txt":"Hello, World!"}' -< HTTP/2 400 -< content-type: text/html -no_text - -$ curl -v $SLACK_WEBHOOK_URL -d '[]' -< HTTP/2 400 -< content-type: text/html -invalid_payload +$ export SLACK_URL=http://elastic:changeme@localhost:5620/api/_actions-FTS-external-service-simulators/slack +$ curl -v $SLACK_URL -H 'content-type: application/json' -d '{"text":"success"}' +< HTTP/1.1 200 OK +... +ok -$ curl -v $SLACK_WEBHOOK_URL_LESS_ONE_CHAR -d '{"text":"Hello, World!"}' -< HTTP/2 403 -< content-type: text/html -invalid_token - -$ curl -v $SLACK_WEBHOOK_URL -d '{"text":"rate limited yet?"}' -< HTTP/2 429 -< content-type: application/json; charset=utf-8 +$ curl -v $SLACK_URL -H 'content-type: application/json' -d '{"text":"rate_limit"}' +< HTTP/1.1 429 Too Many Requests +... < retry-after: 1 +< {"retry_after":1,"ok":false,"error":"rate_limited"} ``` -abuse a server +bonus points: abuse a slack server -------------------------------------------------------------------------------- -To get a rate limiting response, run this in one terminal window, and while -that is running, run a normal curl command to post a message. You may need to -try a few times. +To get a rate limiting slack response, from a real slack server, to see what it +looks like, run this in one terminal window, and while that is running, run a +normal curl command to post a message. You may need to try a few times. You should probably do this with a personal slack instance, not a company one :-) @@ -58,16 +48,3 @@ You should probably do this with a personal slack instance, not a company one :- $ autocannon --amount 10000 --method POST --body '{"text":"Hello, World!"}' $SLACK_WEBHOOK_URL ``` -simulator usage --------------------------------------------------------------------------------- - -These may get out of date, consult the code for exact urls and inputs: - -```console -$ export SLACK_URL=http://localhost:5620/api/_actions-FTS-external-service-simulators/slack - -$ curl -v $SLACK_URL -H 'content-type: application/json' -d '{"text":"slack-success"}' -< HTTP/1.1 200 OK -< content-type: text/html; charset=utf-8 -ok -``` \ No newline at end of file diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index 4f4fd105bf6f44f..2da294ddc551d77 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -5,17 +5,26 @@ */ import Hapi from 'hapi'; import { initPlugin as initSlack } from './slack_simulation'; +import { initPlugin as initPagerduty } from './pagerduty_simulation'; const NAME = 'actions-FTS-external-service-simulators'; export enum ExternalServiceSimulator { SLACK = 'slack', + PAGERDUTY = 'pagerduty', } export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string { return `/api/_${NAME}/${service}`; } +export function getAllExternalServiceSimulatorPaths(): string[] { + return [ + getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK), + getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY), + ]; +} + // eslint-disable-next-line import/no-default-export export default function(kibana: any) { return new kibana.Plugin({ @@ -23,6 +32,7 @@ export default function(kibana: any) { name: NAME, init: (server: Hapi.Server) => { initSlack(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK)); + initPagerduty(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY)); }, }); } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/pagerduty_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/pagerduty_simulation.ts new file mode 100644 index 000000000000000..977424aab88b790 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/pagerduty_simulation.ts @@ -0,0 +1,79 @@ +/* + * 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 Hapi from 'hapi'; + +interface PagerdutyRequest extends Hapi.Request { + payload: { + dedup_key: string; + payload: { + summary: string; + }; + }; +} + +export function initPlugin(server: Hapi.Server, path: string) { + server.route({ + method: 'POST', + path, + options: { + auth: false, + validate: { + options: { abortEarly: false }, + payload: Joi.object() + .unknown(true) + .keys({ + dedup_key: Joi.string(), + payload: Joi.object() + .unknown(true) + .keys({ + summary: Joi.string(), + }), + }), + }, + }, + handler: pagerdutyHandler, + }); +} +// Pagerduty simulator: create an action pointing here, and you can get +// different responses based on the message posted. See the README.md for +// more info. +function pagerdutyHandler(request: PagerdutyRequest, h: any) { + const body = request.payload; + let dedupKey = body && body.dedup_key; + const summary = body && body.payload && body.payload.summary; + + if (dedupKey == null) { + dedupKey = `kibana-ft-simulator-dedup-key-${new Date().toISOString()}`; + } + + switch (summary) { + case 'respond-with-429': + return jsonResponse(h, 429); + case 'respond-with-502': + return jsonResponse(h, 502); + case 'respond-with-418': + return jsonResponse(h, 418); + } + + return jsonResponse(h, 202, { + status: 'success', + message: 'Event processed', + dedup_key: dedupKey, + }); +} + +function jsonResponse(h: any, code: number, object?: any) { + if (object == null) { + return h.response('').code(code); + } + + return h + .response(JSON.stringify(object)) + .type('application/json') + .code(code); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/pagerduty.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/pagerduty.ts new file mode 100644 index 000000000000000..294f119881b70f5 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/pagerduty.ts @@ -0,0 +1,185 @@ +/* + * 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions'; + +// eslint-disable-next-line import/no-default-export +export default function pagerdutyTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('pagerduty action', () => { + let simulatedActionId = ''; + let pagerdutySimulatorURL: string = ''; + + // need to wait for kibanaServer to settle ... + before(() => { + const kibanaServer = getService('kibanaServer'); + const kibanaUrl = kibanaServer.status && kibanaServer.status.kibanaServerUrl; + pagerdutySimulatorURL = `${kibanaUrl}${getExternalServiceSimulatorPath( + ExternalServiceSimulator.PAGERDUTY + )}`; + }); + + after(() => esArchiver.unload('empty_kibana')); + + it('should return successfully when passed valid create parameters', async () => { + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + description: 'A pagerduty action', + actionTypeId: '.pagerduty', + secrets: { + routingKey: 'pager-duty-routing-key', + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + description: 'A pagerduty action', + actionTypeId: '.pagerduty', + config: { + apiUrl: null, + }, + }); + + 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 pagerduty action', + actionTypeId: '.pagerduty', + config: { + apiUrl: null, + }, + }); + }); + + it('should return unsuccessfully when passed invalid create parameters', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + description: 'A pagerduty action', + actionTypeId: '.pagerduty', + secrets: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [routingKey]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should create pagerduty simulator action successfully', async () => { + const { body: createdSimulatedAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + description: 'A pagerduty simulator', + actionTypeId: '.pagerduty', + config: { + apiUrl: pagerdutySimulatorURL, + }, + secrets: { + routingKey: 'pager-duty-routing-key', + }, + }) + .expect(200); + + simulatedActionId = createdSimulatedAction.id; + }); + + it('should handle executing with a simulated success', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + summary: 'just a test', + }, + }) + .expect(200); + expect(result).to.eql({ + status: 'ok', + data: { + dedup_key: `action:${simulatedActionId}`, + message: 'Event processed', + status: 'success', + }, + }); + }); + + it('should handle a 40x pagerduty error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + summary: 'respond-with-418', + }, + }) + .expect(200); + expect(result.status).to.equal('error'); + expect(result.message).to.match( + /error in pagerduty action .+ posting event: unexpected status 418/ + ); + }); + + it('should handle a 429 pagerduty error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + summary: 'respond-with-429', + }, + }) + .expect(200); + + expect(result.status).to.equal('error'); + expect(result.message).to.match( + /error in pagerduty action .+ posting event: status 429, retry later/ + ); + expect(result.retry).to.equal(true); + }); + + it('should handle a 500 pagerduty error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + summary: 'respond-with-502', + }, + }) + .expect(200); + + expect(result.status).to.equal('error'); + expect(result.message).to.match( + /error in pagerduty action .+ posting event: status 502, retry later/ + ); + expect(result.retry).to.equal(true); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 0e830239ca74ca8..0780efc0fc9773e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -20,5 +20,6 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/slack')); loadTestFile(require.resolve('./builtin_action_types/email')); loadTestFile(require.resolve('./builtin_action_types/es_index')); + loadTestFile(require.resolve('./builtin_action_types/pagerduty')); }); }