From a02232d62b63436daa18e145f4caa2f185ab2894 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 10 Feb 2020 12:11:20 +1300 Subject: [PATCH] adds ability to fetch Alert and Alert Instance state (#56625) Enables access to the Alert State, which allows us to see which current Alert Instances are active. This includes: 1. Addition of a `get` api on Task Manager 2. Typing and validation on Serialisation & Deserialisation of the State of an Alert's underlying Task 3. Addition of the `getAlertState` api on AlertsClient --- x-pack/legacy/plugins/alerting/README.md | 9 + .../alert_instance/alert_instance.test.ts | 18 +- .../server/alert_instance/alert_instance.ts | 41 ++-- .../create_alert_instance_factory.test.ts | 2 +- .../alerting/server/alert_instance/index.ts | 2 +- .../alerting/server/alerts_client.mock.ts | 1 + .../alerting/server/alerts_client.test.ts | 114 +++++++++ .../plugins/alerting/server/alerts_client.ts | 12 + .../plugins/alerting/server/lib/types.test.ts | 28 +++ .../plugins/alerting/server/lib/types.ts | 25 ++ .../legacy/plugins/alerting/server/plugin.ts | 2 + .../server/routes/get_alert_state.test.ts | 73 ++++++ .../alerting/server/routes/get_alert_state.ts | 35 +++ .../plugins/alerting/server/routes/index.ts | 1 + .../task_runner/alert_task_instance.test.ts | 229 ++++++++++++++++++ .../server/task_runner/alert_task_instance.ts | 66 +++++ .../server/task_runner/task_runner.ts | 44 ++-- .../legacy/plugins/alerting/server/types.ts | 2 +- .../server/alerts/license_expiration.test.ts | 1 + .../plugins/task_manager/server/legacy.ts | 1 + x-pack/plugins/task_manager/server/README.md | 3 + .../server/create_task_manager.test.ts | 31 +-- x-pack/plugins/task_manager/server/mocks.ts | 1 + x-pack/plugins/task_manager/server/plugin.ts | 3 +- .../task_manager/server/task_manager.mock.ts | 1 + .../task_manager/server/task_manager.ts | 11 + .../common/fixtures/plugins/alerts/index.ts | 26 +- .../tests/alerting/get_alert_state.ts | 126 ++++++++++ .../tests/alerting/index.ts | 1 + .../tests/alerting/get_alert_state.ts | 89 +++++++ .../spaces_only/tests/alerting/index.ts | 1 + .../plugins/task_manager/init_routes.js | 23 +- .../task_manager/task_manager_integration.js | 6 +- 33 files changed, 958 insertions(+), 70 deletions(-) create mode 100644 x-pack/legacy/plugins/alerting/server/lib/types.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/lib/types.ts create mode 100644 x-pack/legacy/plugins/alerting/server/routes/get_alert_state.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/routes/get_alert_state.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts diff --git a/x-pack/legacy/plugins/alerting/README.md b/x-pack/legacy/plugins/alerting/README.md index 4de45fe96a400..eb9df042f9254 100644 --- a/x-pack/legacy/plugins/alerting/README.md +++ b/x-pack/legacy/plugins/alerting/README.md @@ -23,6 +23,7 @@ Table of Contents - [`DELETE /api/alert/{id}`: Delete alert](#delete-apialertid-delete-alert) - [`GET /api/alert/_find`: Find alerts](#get-apialertfind-find-alerts) - [`GET /api/alert/{id}`: Get alert](#get-apialertid-get-alert) + - [`GET /api/alert/{id}/state`: Get alert state](#get-apialertidstate-get-alert-state) - [`GET /api/alert/types`: List alert types](#get-apialerttypes-list-alert-types) - [`PUT /api/alert/{id}`: Update alert](#put-apialertid-update-alert) - [`POST /api/alert/{id}/_enable`: Enable an alert](#post-apialertidenable-enable-an-alert) @@ -273,6 +274,14 @@ Params: |---|---|---| |id|The id of the alert you're trying to get.|string| +### `GET /api/alert/{id}/state`: Get alert state + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the alert whose state you're trying to get.|string| + ### `GET /api/alert/types`: List alert types No parameters. diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts index 6a80f4d2de4cb..c5f93edfb74e5 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts @@ -192,7 +192,7 @@ describe('updateLastScheduledActions()', () => { state: {}, meta: { lastScheduledActions: { - date: new Date(), + date: new Date().toISOString(), group: 'default', }, }, @@ -216,3 +216,19 @@ describe('toJSON', () => { ); }); }); + +describe('toRaw', () => { + test('returns unserialised underlying state and meta', () => { + const raw = { + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }; + const alertInstance = new AlertInstance(raw); + expect(alertInstance.toRaw()).toEqual(raw); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts index a56e2077cdfd8..df67f7d2a1d9e 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts @@ -3,34 +3,41 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import * as t from 'io-ts'; import { State, Context } from '../types'; +import { DateFromString } from '../lib/types'; import { parseDuration } from '../lib'; -interface Meta { - lastScheduledActions?: { - group: string; - date: Date; - }; -} - interface ScheduledExecutionOptions { actionGroup: string; context: Context; state: State; } -interface ConstructorOptions { - state?: State; - meta?: Meta; -} +const metaSchema = t.partial({ + lastScheduledActions: t.type({ + group: t.string, + date: DateFromString, + }), +}); +type AlertInstanceMeta = t.TypeOf; + +const stateSchema = t.record(t.string, t.unknown); +type AlertInstanceState = t.TypeOf; + +export const rawAlertInstance = t.partial({ + state: stateSchema, + meta: metaSchema, +}); +export type RawAlertInstance = t.TypeOf; export class AlertInstance { private scheduledExecutionOptions?: ScheduledExecutionOptions; - private meta: Meta; - private state: State; + private meta: AlertInstanceMeta; + private state: AlertInstanceState; - constructor({ state = {}, meta = {} }: ConstructorOptions = {}) { + constructor({ state = {}, meta = {} }: RawAlertInstance = {}) { this.state = state; this.meta = meta; } @@ -48,7 +55,7 @@ export class AlertInstance { if ( this.meta.lastScheduledActions && this.meta.lastScheduledActions.group === actionGroup && - new Date(this.meta.lastScheduledActions.date).getTime() + throttleMills > Date.now() + this.meta.lastScheduledActions.date.getTime() + throttleMills > Date.now() ) { return true; } @@ -89,6 +96,10 @@ export class AlertInstance { * Used to serialize alert instance state */ toJSON() { + return rawAlertInstance.encode(this.toRaw()); + } + + toRaw(): RawAlertInstance { return { state: this.state, meta: this.meta, diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts index 914f726ebbd78..03bc8b7cc3b14 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts @@ -40,7 +40,7 @@ test('reuses existing instances', () => { Object { "meta": Object { "lastScheduledActions": Object { - "date": 1970-01-01T00:00:00.000Z, + "date": "1970-01-01T00:00:00.000Z", "group": "default", }, }, diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts index 40ee0874e805c..fc828096adf28 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AlertInstance } from './alert_instance'; +export { AlertInstance, RawAlertInstance, rawAlertInstance } from './alert_instance'; export { createAlertInstanceFactory } from './create_alert_instance_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts index c7d359491680f..3189fa214d5f7 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts @@ -12,6 +12,7 @@ const createAlertsClientMock = () => { const mocked: jest.Mocked = { create: jest.fn(), get: jest.fn(), + getAlertState: jest.fn(), find: jest.fn(), delete: jest.fn(), update: jest.fn(), diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts index 56ccf08d6a44f..f9d1d97a521fe 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -1356,6 +1356,120 @@ describe('get()', () => { }); }); +describe('getAlertState()', () => { + test('calls saved objects client with given params', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + taskManager.get.mockResolvedValueOnce({ + id: '1', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + + await alertsClient.getAlertState({ id: '1' }); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alert", + "1", + ] + `); + }); + + test('gets the underlying task from TaskManager', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + + const scheduledTaskId = 'task-123'; + + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + enabled: true, + scheduledTaskId, + mutedInstanceIds: [], + muteAll: true, + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + taskManager.get.mockResolvedValueOnce({ + id: scheduledTaskId, + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: { + alertId: '1', + }, + ownerId: null, + }); + + await alertsClient.getAlertState({ id: '1' }); + expect(taskManager.get).toHaveBeenCalledTimes(1); + expect(taskManager.get).toHaveBeenCalledWith(scheduledTaskId); + }); +}); + describe('find()', () => { test('calls saved objects client with given params', async () => { const alertsClient = new AlertsClient(alertsClientParams); diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index 40125f3067ee3..f6841ed5a0e46 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -31,6 +31,7 @@ import { } from '../../../../plugins/security/server'; import { EncryptedSavedObjectsPluginStart } from '../../../../plugins/encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; +import { AlertTaskState, taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = @@ -204,6 +205,17 @@ export class AlertsClient { return this.getAlertFromRaw(result.id, result.attributes, result.updated_at, result.references); } + public async getAlertState({ id }: { id: string }): Promise { + const alert = await this.get({ id }); + if (alert.scheduledTaskId) { + const { state } = taskInstanceToAlertTaskInstance( + await this.taskManager.get(alert.scheduledTaskId), + alert + ); + return state; + } + } + public async find({ options = {} }: FindOptions = {}): Promise { const { page, diff --git a/x-pack/legacy/plugins/alerting/server/lib/types.test.ts b/x-pack/legacy/plugins/alerting/server/lib/types.test.ts new file mode 100644 index 0000000000000..517b66aa2faab --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/types.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { DateFromString } from './types'; +import { right, isLeft } from 'fp-ts/lib/Either'; + +describe('DateFromString', () => { + test('validated and parses a string into a Date', () => { + const date = new Date(1973, 10, 30); + expect(DateFromString.decode(date.toISOString())).toEqual(right(date)); + }); + + test('validated and returns a failure for an actual Date', () => { + const date = new Date(1973, 10, 30); + expect(isLeft(DateFromString.decode(date))).toEqual(true); + }); + + test('validated and returns a failure for an invalid Date string', () => { + expect(isLeft(DateFromString.decode('1234-23-45'))).toEqual(true); + }); + + test('validated and returns a failure for a null value', () => { + expect(isLeft(DateFromString.decode(null))).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/types.ts b/x-pack/legacy/plugins/alerting/server/lib/types.ts new file mode 100644 index 0000000000000..6df593ab17ce8 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/types.ts @@ -0,0 +1,25 @@ +/* + * 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 * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; +// represents a Date from an ISO string +export const DateFromString = new t.Type( + 'DateFromString', + // detect the type + (value): value is Date => value instanceof Date, + (valueToDecode, context) => + either.chain( + // validate this is a string + t.string.validate(valueToDecode, context), + // decode + value => { + const decoded = new Date(value); + return isNaN(decoded.getTime()) ? t.failure(valueToDecode, context) : t.success(decoded); + } + ), + valueToEncode => valueToEncode.toISOString() +); diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index a4de7af376fb0..e3f7656002d18 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -25,6 +25,7 @@ import { deleteAlertRoute, findAlertRoute, getAlertRoute, + getAlertStateRoute, listAlertTypesRoute, updateAlertRoute, enableAlertRoute, @@ -92,6 +93,7 @@ export class Plugin { core.http.route(extendRouteWithLicenseCheck(deleteAlertRoute, this.licenseState)); core.http.route(extendRouteWithLicenseCheck(findAlertRoute, this.licenseState)); core.http.route(extendRouteWithLicenseCheck(getAlertRoute, this.licenseState)); + core.http.route(extendRouteWithLicenseCheck(getAlertStateRoute, this.licenseState)); core.http.route(extendRouteWithLicenseCheck(listAlertTypesRoute, this.licenseState)); core.http.route(extendRouteWithLicenseCheck(updateAlertRoute, this.licenseState)); core.http.route(extendRouteWithLicenseCheck(enableAlertRoute, this.licenseState)); diff --git a/x-pack/legacy/plugins/alerting/server/routes/get_alert_state.test.ts b/x-pack/legacy/plugins/alerting/server/routes/get_alert_state.test.ts new file mode 100644 index 0000000000000..9e3b3b6579ead --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/get_alert_state.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { getAlertStateRoute } from './get_alert_state'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; + +const { server, alertsClient } = createMockServer(); +server.route(getAlertStateRoute); + +const mockedAlertState = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date(), + }, + }, + }, + second_instance: {}, + }, +}; + +beforeEach(() => jest.resetAllMocks()); + +test('gets alert state', async () => { + const request = { + method: 'GET', + url: '/api/alert/1/state', + }; + + alertsClient.getAlertState.mockResolvedValueOnce(mockedAlertState); + + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + expect(alertsClient.getAlertState).toHaveBeenCalledWith({ id: '1' }); +}); + +test('returns NO-CONTENT when alert exists but has no task state yet', async () => { + const request = { + method: 'GET', + url: '/api/alert/1/state', + }; + + alertsClient.getAlertState.mockResolvedValueOnce(undefined); + + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(204); + expect(alertsClient.getAlertState).toHaveBeenCalledWith({ id: '1' }); +}); + +test('returns NOT-FOUND when alert is not found', async () => { + const request = { + method: 'GET', + url: '/api/alert/1/state', + }; + + alertsClient.getAlertState.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError('alert', '1') + ); + + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(404); + expect(alertsClient.getAlertState).toHaveBeenCalledWith({ id: '1' }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/get_alert_state.ts b/x-pack/legacy/plugins/alerting/server/routes/get_alert_state.ts new file mode 100644 index 0000000000000..12136a975bb19 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/get_alert_state.ts @@ -0,0 +1,35 @@ +/* + * 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 GetAlertStateRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export const getAlertStateRoute = { + method: 'GET', + path: '/api/alert/{id}/state', + options: { + tags: ['access:alerting-read'], + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: GetAlertStateRequest, h: Hapi.ResponseToolkit) { + const { id } = request.params; + const alertsClient = request.getAlertsClient!(); + const state = await alertsClient.getAlertState({ id }); + return state ? state : h.response().code(204); + }, +}; diff --git a/x-pack/legacy/plugins/alerting/server/routes/index.ts b/x-pack/legacy/plugins/alerting/server/routes/index.ts index 02cba8adc9db2..7ec901ae685c4 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/index.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/index.ts @@ -8,6 +8,7 @@ export { createAlertRoute } from './create'; export { deleteAlertRoute } from './delete'; export { findAlertRoute } from './find'; export { getAlertRoute } from './get'; +export { getAlertStateRoute } from './get_alert_state'; export { listAlertTypesRoute } from './list_alert_types'; export { updateAlertRoute } from './update'; export { enableAlertRoute } from './enable'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.test.ts new file mode 100644 index 0000000000000..9cbe91a4dbced --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.test.ts @@ -0,0 +1,229 @@ +/* + * 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 { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; +import { AlertTaskInstance, taskInstanceToAlertTaskInstance } from './alert_task_instance'; +import uuid from 'uuid'; +import { SanitizedAlert } from '../types'; + +const alert: SanitizedAlert = { + id: 'alert-123', + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + enabled: true, + name: '', + tags: [], + consumer: '', + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], +}; + +describe('Alert Task Instance', () => { + test(`validates that a TaskInstance has valid Alert Task State`, () => { + const lastScheduledActionsDate = new Date(); + const taskInstance: ConcreteTaskInstance = { + id: uuid.v4(), + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: lastScheduledActionsDate.toISOString(), + }, + }, + }, + second_instance: {}, + }, + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + + const alertTaskInsatnce: AlertTaskInstance = taskInstanceToAlertTaskInstance(taskInstance); + + expect(alertTaskInsatnce).toEqual({ + ...taskInstance, + state: { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: lastScheduledActionsDate, + }, + }, + }, + second_instance: {}, + }, + }, + }); + }); + + test(`throws if state is invalid`, () => { + const taskInstance: ConcreteTaskInstance = { + id: '215ee69b-1df9-428e-ab1a-ccf274f8fa5b', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: 'invalid', + second_instance: {}, + }, + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + + expect(() => taskInstanceToAlertTaskInstance(taskInstance)).toThrowErrorMatchingInlineSnapshot( + `"Task \\"215ee69b-1df9-428e-ab1a-ccf274f8fa5b\\" has invalid state at .alertInstances.first_instance"` + ); + }); + + test(`throws with Alert id when alert is present and state is invalid`, () => { + const taskInstance: ConcreteTaskInstance = { + id: '215ee69b-1df9-428e-ab1a-ccf274f8fa5b', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: 'invalid', + second_instance: {}, + }, + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + + expect(() => + taskInstanceToAlertTaskInstance(taskInstance, alert) + ).toThrowErrorMatchingInlineSnapshot( + `"Task \\"215ee69b-1df9-428e-ab1a-ccf274f8fa5b\\" (underlying Alert \\"alert-123\\") has invalid state at .alertInstances.first_instance"` + ); + }); + + test(`allows an initial empty state`, () => { + const taskInstance: ConcreteTaskInstance = { + id: uuid.v4(), + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: {}, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + + const alertTaskInsatnce: AlertTaskInstance = taskInstanceToAlertTaskInstance(taskInstance); + + expect(alertTaskInsatnce).toEqual(taskInstance); + }); + + test(`validates that a TaskInstance has valid Params`, () => { + const taskInstance: ConcreteTaskInstance = { + id: uuid.v4(), + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: {}, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + + const alertTaskInsatnce: AlertTaskInstance = taskInstanceToAlertTaskInstance( + taskInstance, + alert + ); + + expect(alertTaskInsatnce).toEqual(taskInstance); + }); + + test(`throws if params are invalid`, () => { + const taskInstance: ConcreteTaskInstance = { + id: '215ee69b-1df9-428e-ab1a-ccf274f8fa5b', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: {}, + taskType: 'alerting:test', + params: {}, + ownerId: null, + }; + + expect(() => + taskInstanceToAlertTaskInstance(taskInstance, alert) + ).toThrowErrorMatchingInlineSnapshot( + `"Task \\"215ee69b-1df9-428e-ab1a-ccf274f8fa5b\\" (underlying Alert \\"alert-123\\") has an invalid param at .0.alertId"` + ); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts new file mode 100644 index 0000000000000..33b416fe8e2da --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/alert_task_instance.ts @@ -0,0 +1,66 @@ +/* + * 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 * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; +import { SanitizedAlert } from '../types'; +import { DateFromString } from '../lib/types'; +import { AlertInstance, rawAlertInstance } from '../alert_instance'; + +export interface AlertTaskInstance extends ConcreteTaskInstance { + state: AlertTaskState; +} + +export const alertStateSchema = t.partial({ + alertTypeState: t.record(t.string, t.unknown), + alertInstances: t.record(t.string, rawAlertInstance), + previousStartedAt: t.union([t.null, DateFromString]), +}); +export type AlertInstances = Record; +export type AlertTaskState = t.TypeOf; + +const alertParamsSchema = t.intersection([ + t.type({ + alertId: t.string, + }), + t.partial({ + spaceId: t.string, + }), +]); +export type AlertTaskParams = t.TypeOf; + +const enumerateErrorFields = (e: t.Errors) => + `${e.map(({ context }) => context.map(({ key }) => key).join('.'))}`; + +export function taskInstanceToAlertTaskInstance( + taskInstance: ConcreteTaskInstance, + alert?: SanitizedAlert +): AlertTaskInstance { + return { + ...taskInstance, + params: pipe( + alertParamsSchema.decode(taskInstance.params), + fold((e: t.Errors) => { + throw new Error( + `Task "${taskInstance.id}" ${ + alert ? `(underlying Alert "${alert.id}") ` : '' + }has an invalid param at ${enumerateErrorFields(e)}` + ); + }, t.identity) + ), + state: pipe( + alertStateSchema.decode(taskInstance.state), + fold((e: t.Errors) => { + throw new Error( + `Task "${taskInstance.id}" ${ + alert ? `(underlying Alert "${alert.id}") ` : '' + }has invalid state at ${enumerateErrorFields(e)}` + ); + }, t.identity) + ), + }; +} diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 0f643e3d3121c..1466d3ccd274b 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -10,25 +10,32 @@ import { SavedObject } from '../../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; -import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; +import { AlertInstance, createAlertInstanceFactory, RawAlertInstance } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; -import { AlertType, RawAlert, IntervalSchedule, Services, State, AlertInfoParams } from '../types'; +import { AlertType, RawAlert, IntervalSchedule, Services, AlertInfoParams } from '../types'; import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; - -type AlertInstances = Record; +import { + AlertTaskState, + AlertInstances, + taskInstanceToAlertTaskInstance, +} from './alert_task_instance'; const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; interface AlertTaskRunResult { - state: State; + state: AlertTaskState; runAt: Date; } +interface AlertTaskInstance extends ConcreteTaskInstance { + state: AlertTaskState; +} + export class TaskRunner { private context: TaskRunnerContext; private logger: Logger; - private taskInstance: ConcreteTaskInstance; + private taskInstance: AlertTaskInstance; private alertType: AlertType; constructor( @@ -39,7 +46,7 @@ export class TaskRunner { this.context = context; this.logger = context.logger; this.alertType = alertType; - this.taskInstance = taskInstance; + this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); } async getApiKeyForAlertPermissions(alertId: string, spaceId: string) { @@ -128,7 +135,7 @@ export class TaskRunner { alertInfoParams: AlertInfoParams, executionHandler: ReturnType, spaceId: string - ): Promise { + ): Promise { const { params, throttle, @@ -145,9 +152,9 @@ export class TaskRunner { } = this.taskInstance; const namespace = this.context.spaceIdToNamespace(spaceId); - const alertInstances = mapValues( + const alertInstances = mapValues( alertRawInstances, - alert => new AlertInstance(alert) + rawAlertInstance => new AlertInstance(rawAlertInstance) ); const updatedAlertTypeState = await this.alertType.executor({ @@ -159,7 +166,7 @@ export class TaskRunner { params, state: alertTypeState, startedAt: this.taskInstance.startedAt!, - previousStartedAt: previousStartedAt && new Date(previousStartedAt), + previousStartedAt: previousStartedAt ? new Date(previousStartedAt) : null, spaceId, namespace, name, @@ -171,7 +178,7 @@ export class TaskRunner { // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object const instancesWithScheduledActions = pick( alertInstances, - alertInstance => alertInstance.hasScheduledActions() + (alertInstance: AlertInstance) => alertInstance.hasScheduledActions() ); if (!muteAll) { @@ -192,8 +199,11 @@ export class TaskRunner { } return { - alertTypeState: updatedAlertTypeState, - alertInstances: instancesWithScheduledActions, + alertTypeState: updatedAlertTypeState || undefined, + alertInstances: mapValues( + instancesWithScheduledActions, + alertInstance => alertInstance.toRaw() + ), }; } @@ -239,7 +249,7 @@ export class TaskRunner { ); return { - state: await promiseResult( + state: await promiseResult( this.validateAndExecuteAlert(services, apiKey, attributes, references) ), runAt: asOk( @@ -264,9 +274,9 @@ export class TaskRunner { const { state, runAt } = await errorAsAlertTaskRunResult(this.loadAlertAttributesAndRun()); return { - state: map( + state: map( state, - (stateUpdates: State) => { + (stateUpdates: AlertTaskState) => { return { ...stateUpdates, previousStartedAt, diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 9c4a64ff02105..5aef3b1337a88 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -31,7 +31,7 @@ export interface AlertServices extends Services { export interface AlertExecutorOptions { alertId: string; startedAt: Date; - previousStartedAt?: Date; + previousStartedAt: Date | null; services: AlertServices; params: Record; state: State; diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.test.ts b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.test.ts index 2fec949f5692e..ec00ece9e6ee2 100644 --- a/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.test.ts +++ b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.test.ts @@ -63,6 +63,7 @@ const alertExecutorOptions: LicenseExpirationAlertExecutorOptions = { spaceId: '', name: '', tags: [], + previousStartedAt: null, createdBy: null, updatedBy: null, }; diff --git a/x-pack/legacy/plugins/task_manager/server/legacy.ts b/x-pack/legacy/plugins/task_manager/server/legacy.ts index f5e81bfd90169..cd2047b757e61 100644 --- a/x-pack/legacy/plugins/task_manager/server/legacy.ts +++ b/x-pack/legacy/plugins/task_manager/server/legacy.ts @@ -47,6 +47,7 @@ export function createLegacyApi(legacyTaskManager: Promise): Legacy legacyTaskManager.then((tm: TaskManager) => tm.registerTaskDefinitions(taskDefinitions)); }, fetch: (opts: SearchOpts) => legacyTaskManager.then((tm: TaskManager) => tm.fetch(opts)), + get: (id: string) => legacyTaskManager.then((tm: TaskManager) => tm.get(id)), remove: (id: string) => legacyTaskManager.then((tm: TaskManager) => tm.remove(id)), schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => legacyTaskManager.then((tm: TaskManager) => tm.schedule(taskInstance, options)), diff --git a/x-pack/plugins/task_manager/server/README.md b/x-pack/plugins/task_manager/server/README.md index a067358dc8841..a4154f3ecf212 100644 --- a/x-pack/plugins/task_manager/server/README.md +++ b/x-pack/plugins/task_manager/server/README.md @@ -261,6 +261,9 @@ The _Start_ Plugin api allow you to use Task Manager to facilitate your Plugin's remove: (id: string) => { // ... }, + get: (id: string) => { + // ... + }, schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => { // ... }, diff --git a/x-pack/plugins/task_manager/server/create_task_manager.test.ts b/x-pack/plugins/task_manager/server/create_task_manager.test.ts index 34258e15f45d1..133cfcac4c046 100644 --- a/x-pack/plugins/task_manager/server/create_task_manager.test.ts +++ b/x-pack/plugins/task_manager/server/create_task_manager.test.ts @@ -42,20 +42,21 @@ describe('createTaskManager', () => { const mockLegacyDeps = getMockLegacyDeps(); const setupResult = createTaskManager(mockCoreSetup, mockLegacyDeps); expect(setupResult).toMatchInlineSnapshot(` - TaskManager { - "addMiddleware": [MockFunction], - "assertUninitialized": [MockFunction], - "attemptToRun": [MockFunction], - "ensureScheduled": [MockFunction], - "fetch": [MockFunction], - "registerTaskDefinitions": [MockFunction], - "remove": [MockFunction], - "runNow": [MockFunction], - "schedule": [MockFunction], - "start": [MockFunction], - "stop": [MockFunction], - "waitUntilStarted": [MockFunction], - } - `); + TaskManager { + "addMiddleware": [MockFunction], + "assertUninitialized": [MockFunction], + "attemptToRun": [MockFunction], + "ensureScheduled": [MockFunction], + "fetch": [MockFunction], + "get": [MockFunction], + "registerTaskDefinitions": [MockFunction], + "remove": [MockFunction], + "runNow": [MockFunction], + "schedule": [MockFunction], + "start": [MockFunction], + "stop": [MockFunction], + "waitUntilStarted": [MockFunction], + } + `); }); }); diff --git a/x-pack/plugins/task_manager/server/mocks.ts b/x-pack/plugins/task_manager/server/mocks.ts index 00b27bd55e7dd..8ec05dd1bd401 100644 --- a/x-pack/plugins/task_manager/server/mocks.ts +++ b/x-pack/plugins/task_manager/server/mocks.ts @@ -18,6 +18,7 @@ const createSetupMock = () => { const createStartMock = () => { const mock: jest.Mocked = { fetch: jest.fn(), + get: jest.fn(), remove: jest.fn(), schedule: jest.fn(), runNow: jest.fn(), diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index 5e59be65c729d..fdfe0c068afcf 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -21,7 +21,7 @@ export type TaskManagerSetupContract = { export type TaskManagerStartContract = Pick< TaskManager, - 'fetch' | 'remove' | 'schedule' | 'runNow' | 'ensureScheduled' + 'fetch' | 'get' | 'remove' | 'schedule' | 'runNow' | 'ensureScheduled' >; export class TaskManagerPlugin @@ -69,6 +69,7 @@ export class TaskManagerPlugin public start(): TaskManagerStartContract { return { fetch: (...args) => this.taskManager.then(tm => tm.fetch(...args)), + get: (...args) => this.taskManager.then(tm => tm.get(...args)), remove: (...args) => this.taskManager.then(tm => tm.remove(...args)), schedule: (...args) => this.taskManager.then(tm => tm.schedule(...args)), runNow: (...args) => this.taskManager.then(tm => tm.runNow(...args)), diff --git a/x-pack/plugins/task_manager/server/task_manager.mock.ts b/x-pack/plugins/task_manager/server/task_manager.mock.ts index 89d1210b00671..1be1a81cdeb68 100644 --- a/x-pack/plugins/task_manager/server/task_manager.mock.ts +++ b/x-pack/plugins/task_manager/server/task_manager.mock.ts @@ -21,6 +21,7 @@ export const taskManagerMock = { ensureScheduled: jest.fn(), schedule: jest.fn(), fetch: jest.fn(), + get: jest.fn(), runNow: jest.fn(), remove: jest.fn(), ...overrides, diff --git a/x-pack/plugins/task_manager/server/task_manager.ts b/x-pack/plugins/task_manager/server/task_manager.ts index da9640fa3e071..641826de615b1 100644 --- a/x-pack/plugins/task_manager/server/task_manager.ts +++ b/x-pack/plugins/task_manager/server/task_manager.ts @@ -333,6 +333,17 @@ export class TaskManager { return this.store.fetch(opts); } + /** + * Get the current state of a specified task. + * + * @param {string} id + * @returns {Promise} + */ + public async get(id: string): Promise { + await this.waitUntilStarted(); + return this.store.get(id); + } + /** * Removes the specified task from the index. * diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index 6c2a22f2737fe..f7f3d0fa91fff 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { times } from 'lodash'; import { schema } from '@kbn/config-schema'; import { AlertExecutorOptions, AlertType } from '../../../../../../legacy/plugins/alerting'; import { ActionTypeExecutorOptions, ActionType } from '../../../../../../plugins/actions/server'; @@ -249,6 +249,29 @@ export default function(kibana: any) { }; }, }; + // Alert types + const cumulativeFiringAlertType: AlertType = { + id: 'test.cumulative-firing', + name: 'Test: Cumulative Firing', + actionGroups: ['default', 'other'], + async executor(alertExecutorOptions: AlertExecutorOptions) { + const { services, state } = alertExecutorOptions; + const group = 'default'; + + const runCount = (state.runCount || 0) + 1; + + times(runCount, index => { + services + .alertInstanceFactory(`instance-${index}`) + .replaceState({ instanceStateValue: true }) + .scheduleActions(group); + }); + + return { + runCount, + }; + }, + }; const neverFiringAlertType: AlertType = { id: 'test.never-firing', name: 'Test: Never firing', @@ -364,6 +387,7 @@ export default function(kibana: any) { async executor({ services, params, state }: AlertExecutorOptions) {}, }; server.plugins.alerting.setup.registerType(alwaysFiringAlertType); + server.plugins.alerting.setup.registerType(cumulativeFiringAlertType); server.plugins.alerting.setup.registerType(neverFiringAlertType); server.plugins.alerting.setup.registerType(failingAlertType); server.plugins.alerting.setup.registerType(validationAlertType); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts new file mode 100644 index 0000000000000..d95f9ea8ac0ea --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts @@ -0,0 +1,126 @@ +/* + * 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 { getUrlPrefix, ObjectRemover, getTestAlertData } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { UserAtSpaceScenarios } from '../../scenarios'; + +// eslint-disable-next-line import/no-default-export +export default function createGetAlertStateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('getAlertState', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle getAlertState alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alert/${createdAlert.id}/state`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.key('alertInstances', 'previousStartedAt'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`shouldn't getAlertState for an alert from another space`, async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix('other')}/api/alert/${createdAlert.id}/state`) + .auth(user.username, user.password); + + expect(response.statusCode).to.eql(404); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [alert/${createdAlert.id}] not found`, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle getAlertState request appropriately when alert doesn't exist`, async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alert/1/state`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index 1aa084356cfa4..91b0ca0a37c92 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -15,6 +15,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./enable')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./list_alert_types')); loadTestFile(require.resolve('./mute_all')); loadTestFile(require.resolve('./mute_instance')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts new file mode 100644 index 0000000000000..053df3b7199cc --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts @@ -0,0 +1,89 @@ +/* + * 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 { Spaces } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover, getTestAlertData } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetAlertStateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('getAlertState', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + it('should handle getAlertState request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert'); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alert/${createdAlert.id}/state` + ); + + expect(response.statusCode).to.eql(200); + expect(response.body).to.key('alertInstances', 'previousStartedAt'); + }); + + it('should fetch updated state', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send({ + enabled: true, + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.cumulative-firing', + consumer: 'bar', + schedule: { interval: '5s' }, + throttle: '5s', + actions: [], + params: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert'); + + // wait for alert to actually execute + await retry.try(async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alert/${createdAlert.id}/state` + ); + + expect(response.statusCode).to.eql(200); + expect(response.body).to.key('alertInstances', 'alertTypeState', 'previousStartedAt'); + expect(response.body.alertTypeState.runCount).to.greaterThan(1); + }); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alert/${createdAlert.id}/state` + ); + + expect(response.body.alertTypeState.runCount).to.greaterThan(0); + + const alertInstances = Object.entries>(response.body.alertInstances); + expect(alertInstances.length).to.eql(response.body.alertTypeState.runCount); + alertInstances.forEach(([key, value], index) => { + expect(key).to.eql(`instance-${index}`); + expect(value.state).to.eql({ instanceStateValue: true }); + }); + }); + + it(`should handle getAlertState request appropriately when alert doesn't exist`, async () => { + await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/alert/1/state`).expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 569c0d538d473..0b7f51ac9a79b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -15,6 +15,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./enable')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./list_alert_types')); loadTestFile(require.resolve('./mute_all')); loadTestFile(require.resolve('./mute_instance')); diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js index 9e818f050c929..785fbed341423 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js @@ -24,6 +24,14 @@ const taskManagerQuery = { }; export function initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents) { + const callCluster = server.plugins.elasticsearch.getCluster('admin').callWithInternalUser; + + async function ensureIndexIsRefreshed() { + return await callCluster('indices.refresh', { + index: '.kibana_task_manager', + }); + } + server.route({ path: '/api/sample_tasks/schedule', method: 'POST', @@ -198,19 +206,8 @@ export function initRoutes(server, taskManager, legacyTaskManager, taskTestingEv method: 'GET', async handler(request) { try { - return taskManager.fetch({ - query: { - bool: { - must: [ - { - ids: { - values: [`task:${request.params.taskId}`], - }, - }, - ], - }, - }, - }); + await ensureIndexIsRefreshed(); + return await taskManager.get(request.params.taskId); } catch (err) { return err; } diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js index 7ec0e9b5efa5b..e8f976d5ae6e3 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js @@ -69,7 +69,7 @@ export default function({ getService }) { .get(`/api/sample_tasks/task/${task}`) .send({ task }) .expect(200) - .then(response => response.body.docs[0]); + .then(response => response.body); } function historyDocs(taskId) { @@ -434,9 +434,7 @@ export default function({ getService }) { expect(successfulRunNowResult).to.eql({ id: originalTask.id }); await retry.try(async () => { - const [task] = (await currentTasks()).docs.filter( - taskDoc => taskDoc.id === originalTask.id - ); + const task = await currentTask(originalTask.id); expect(task.state.count).to.eql(2); });