From 044a5c129fe79d88b911d49a4fbeba20ea5d9c78 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 16 Jun 2021 12:09:02 +0200 Subject: [PATCH] Unit tests for create_lifecycle_rule_type_factory --- .../create_rule_data_client_mock.ts | 42 ++ .../utils/create_lifecycle_rule_type.test.ts | 381 ++++++++++++++++++ .../create_lifecycle_rule_type_factory.ts | 4 +- 3 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts create mode 100644 x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts b/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts new file mode 100644 index 00000000000000..18f3c21fafc155 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Assign } from '@kbn/utility-types'; +import type { RuleDataClient } from '.'; +import { RuleDataReader, RuleDataWriter } from './types'; + +type MockInstances> = { + [K in keyof T]: T[K] extends (...args: infer TArgs) => infer TReturn + ? jest.MockInstance + : never; +}; + +export function createRuleDataClientMock() { + const bulk = jest.fn(); + const search = jest.fn(); + const getDynamicIndexPattern = jest.fn(); + + return ({ + createOrUpdateWriteTarget: jest.fn(({ namespace }) => Promise.resolve()), + getReader: jest.fn(() => ({ + getDynamicIndexPattern, + search, + })), + getWriter: jest.fn(() => ({ + bulk, + })), + } as unknown) as Assign< + RuleDataClient & Omit, 'options' | 'getClusterClient'>, + { + getWriter: ( + ...args: Parameters + ) => MockInstances; + getReader: ( + ...args: Parameters + ) => MockInstances; + } + >; +} diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts new file mode 100644 index 00000000000000..85e69eb51fd02f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -0,0 +1,381 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { loggerMock } from '@kbn/logging/target/mocks'; +import { castArray, omit, mapValues } from 'lodash'; +import { RuleDataClient } from '../rule_data_client'; +import { createRuleDataClientMock } from '../rule_data_client/create_rule_data_client_mock'; +import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory'; + +type RuleTestHelpers = ReturnType; + +function createRule() { + const ruleDataClientMock = createRuleDataClientMock(); + + const factory = createLifecycleRuleTypeFactory({ + ruleDataClient: (ruleDataClientMock as unknown) as RuleDataClient, + logger: loggerMock.create(), + }); + + let nextAlerts: Array<{ id: string; fields: Record }> = []; + + const type = factory({ + actionGroups: [ + { + id: 'warning', + name: 'warning', + }, + ], + defaultActionGroupId: 'warning', + executor: async ({ services }) => { + nextAlerts.forEach((alert) => { + services.alertWithLifecycle(alert); + }); + nextAlerts = []; + }, + id: 'test_type', + minimumLicenseRequired: 'basic', + name: 'Test type', + producer: 'test', + actionVariables: { + context: [], + params: [], + state: [], + }, + validate: { + params: schema.object({}, { unknowns: 'allow' }), + }, + }); + + let state: Record = {}; + let previousStartedAt: Date | null; + const createdAt = new Date('2021-06-16T09:00:00.000Z'); + + const scheduleActions = jest.fn(); + + const alertInstanceFactory = () => { + return { + scheduleActions, + } as any; + }; + + return { + alertWithLifecycle: async (alerts: Array<{ id: string; fields: Record }>) => { + nextAlerts = alerts; + + const startedAt = new Date((previousStartedAt ?? createdAt).getTime() + 60000); + + scheduleActions.mockClear(); + + state = await type.executor({ + alertId: 'alertId', + createdBy: 'createdBy', + name: 'name', + params: {}, + previousStartedAt, + startedAt, + rule: { + actions: [], + consumer: 'consumer', + createdAt, + createdBy: 'createdBy', + enabled: true, + name: 'name', + notifyWhen: 'onActionGroupChange', + producer: 'producer', + ruleTypeId: 'ruleTypeId', + ruleTypeName: 'ruleTypeName', + schedule: { + interval: '1m', + }, + tags: ['tags'], + throttle: null, + updatedAt: createdAt, + updatedBy: 'updatedBy', + }, + services: { + alertInstanceFactory, + savedObjectsClient: {} as any, + scopedClusterClient: {} as any, + }, + spaceId: 'spaceId', + state, + tags: ['tags'], + updatedBy: 'updatedBy', + namespace: 'namespace', + }); + + previousStartedAt = startedAt; + }, + scheduleActions, + ruleDataClientMock, + }; +} + +describe('createLifecycleRuleTypeFactory', () => { + describe('with a new rule', () => { + let helpers: RuleTestHelpers; + + beforeEach(() => { + helpers = createRule(); + }); + + describe('when alerts are new', () => { + beforeEach(async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + }); + + it('writes the correct alerts', () => { + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(1); + + const body = helpers.ruleDataClientMock.getWriter().bulk.mock.calls[0][0].body!; + + const documents = body.filter((op: any) => !('index' in op)) as any[]; + + const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); + const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); + + expect(evaluationDocuments.length).toBe(2); + expect(alertDocuments.length).toBe(2); + + expect( + alertDocuments.every((doc) => doc['kibana.rac.alert.status'] === 'open') + ).toBeTruthy(); + + expect( + alertDocuments.every((doc) => doc['kibana.rac.alert.duration.us'] === 0) + ).toBeTruthy(); + + expect(alertDocuments.every((doc) => doc['event.action'] === 'open')).toBeTruthy(); + + expect(documents.map((doc) => omit(doc, 'kibana.rac.alert.uuid'))).toMatchInlineSnapshot(` + Array [ + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "event", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-java", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-java", + "tags": Array [ + "tags", + ], + }, + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "event", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-node", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-node", + "tags": Array [ + "tags", + ], + }, + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "signal", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-java", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-java", + "tags": Array [ + "tags", + ], + }, + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "signal", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-node", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-node", + "tags": Array [ + "tags", + ], + }, + ] + `); + }); + }); + + describe('when alerts are active', () => { + beforeEach(async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + }); + + it('writes the correct alerts', () => { + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(2); + + const body = helpers.ruleDataClientMock.getWriter().bulk.mock.calls[1][0].body!; + + const documents = body.filter((op: any) => !('index' in op)) as any[]; + + const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); + const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); + + expect(evaluationDocuments.length).toBe(2); + expect(alertDocuments.length).toBe(2); + + expect( + alertDocuments.every((doc) => doc['kibana.rac.alert.status'] === 'open') + ).toBeTruthy(); + expect(alertDocuments.every((doc) => doc['event.action'] === 'active')).toBeTruthy(); + + expect(alertDocuments.every((doc) => doc['kibana.rac.alert.duration.us'] > 0)).toBeTruthy(); + }); + }); + + describe('when alerts recover', () => { + beforeEach(async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + + const lastOpbeansNodeDoc = helpers.ruleDataClientMock + .getWriter() + .bulk.mock.calls[0][0].body?.concat() + .reverse() + .find( + (doc: any) => !('index' in doc) && doc['service.name'] === 'opbeans-node' + ) as Record; + + const stored = mapValues(lastOpbeansNodeDoc, (val) => { + return castArray(val); + }); + + helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ + hits: { + hits: [{ fields: stored } as any], + total: { + value: 1, + relation: 'eq', + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 1, + total: 1, + }, + }); + + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + ]); + }); + + it('writes the correct alerts', () => { + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(2); + + const body = helpers.ruleDataClientMock.getWriter().bulk.mock.calls[1][0].body!; + + const documents = body.filter((op: any) => !('index' in op)) as any[]; + + const opbeansJavaAlertDoc = documents.find( + (doc) => castArray(doc['service.name'])[0] === 'opbeans-java' + ); + const opbeansNodeAlertDoc = documents.find( + (doc) => castArray(doc['service.name'])[0] === 'opbeans-node' + ); + + expect(opbeansJavaAlertDoc['event.action']).toBe('active'); + expect(opbeansJavaAlertDoc['kibana.rac.alert.status']).toBe('open'); + + expect(opbeansNodeAlertDoc['event.action']).toBe('close'); + expect(opbeansNodeAlertDoc['kibana.rac.alert.status']).toBe('closed'); + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index 84ca3cd730f80c..c2e0ae7c151ca0 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -32,7 +32,7 @@ import { AlertTypeWithExecutor } from '../types'; import { ParsedTechnicalFields, parseTechnicalFields } from '../../common/parse_technical_fields'; import { getRuleExecutorData } from './get_rule_executor_data'; -type LifecycleAlertService> = (alert: { +export type LifecycleAlertService> = (alert: { id: string; fields: Record; }) => AlertInstance; @@ -259,7 +259,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ ); return { - wrapped: nextWrappedState, + wrapped: nextWrappedState ?? {}, trackedAlerts: nextTrackedAlerts, }; },