From b1290ceca87e9a385db5e2be574bdd101caa559e Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Mon, 23 Sep 2024 13:30:02 -0400 Subject: [PATCH] [Synthetics] Add tests for default alerting service (#193235) ## Summary As noted in https://github.com/elastic/kibana/pull/193201, I am attempting to increase module-level testing in Synthetics by strategically targeting critical segments of code that are untested, or very lightly tested, at the module level. While this doesn't provide the same quality assurance as e2e tests, it will help increase our confidence when we make changes that those changes have the intended effects, and that they don't introduce small regressions that may go unnoticed in smoke testing or code review. This PR is WIP as I haven't fully tested the module yet and I can only work on this as time allows. --- .../default_alert_service.test.ts | 465 ++++++++++++++++++ .../default_alerts/default_alert_service.ts | 14 +- .../synthetics/tsconfig.json | 3 +- 3 files changed, 474 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts new file mode 100644 index 0000000000000..887d7c71564c2 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts @@ -0,0 +1,465 @@ +/* + * 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 { SanitizedRule } from '@kbn/alerting-types'; +import { omit } from 'lodash'; +import { + SYNTHETICS_STATUS_RULE, + SYNTHETICS_TLS_RULE, +} from '../../../common/constants/synthetics_alerts'; +import { DefaultAlertService } from './default_alert_service'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../constants/settings'; + +describe('DefaultAlertService', () => { + describe('getSettings', () => { + const expectedSettings = { + certAgeThreshold: 50, + certExpirationThreshold: 10, + defaultConnectors: ['slack', 'email'], + }; + const soResponse = { attributes: { ...expectedSettings } }; + it('returns settings if already set', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + service.settings = expectedSettings; + const settings = await service.getSettings(); + expect(settings).toEqual(expectedSettings); + expect(soClient.get).not.toHaveBeenCalled(); + }); + + it('fetches settings if not set', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + soClient.get.mockResolvedValueOnce(soResponse); + const settings = await service.getSettings(); + expect(settings).toEqual({ + ...expectedSettings, + defaultEmail: undefined, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }); + expect(soClient.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('setupDefaultAlerts', () => { + afterEach(() => jest.resetAllMocks()); + + it('sets up status and tls rules', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + service.getSettings = jest.fn().mockResolvedValue({ + certAgeThreshold: 50, + certExpirationThreshold: 10, + defaultConnectors: ['slack', 'email'], + defaultEmail: undefined, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }); + const setupStatusRule = jest.fn(); + const setupTlsRule = jest.fn(); + service.setupStatusRule = setupStatusRule; + service.setupTlsRule = setupTlsRule; + setupStatusRule.mockResolvedValueOnce({ status: 'fulfilled', value: {} }); + setupTlsRule.mockResolvedValueOnce({ status: 'fulfilled', value: {} }); + const result = await service.setupDefaultAlerts(); + expect(setupStatusRule).toHaveBeenCalledTimes(1); + expect(setupTlsRule).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + statusRule: { status: 'fulfilled', value: {} }, + tlsRule: { status: 'fulfilled', value: {} }, + }); + }); + it('returns null rules if value is falsy', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + service.getSettings = jest.fn().mockResolvedValue({ + certAgeThreshold: 50, + certExpirationThreshold: 10, + defaultConnectors: ['slack', 'email'], + defaultEmail: undefined, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }); + const setupStatusRule = jest.fn(); + const setupTlsRule = jest.fn(); + service.setupStatusRule = setupStatusRule; + service.setupTlsRule = setupTlsRule; + setupStatusRule.mockResolvedValueOnce(undefined); + setupTlsRule.mockResolvedValueOnce(undefined); + const result = await service.setupDefaultAlerts(); + expect(setupStatusRule).toHaveBeenCalledTimes(1); + expect(setupTlsRule).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + statusRule: null, + tlsRule: null, + }); + }); + }); + + describe('getMinimumRuleInterval', () => { + it('returns 1m if minimum interval is less than 1m', () => { + const server = { + alerting: { getConfig: () => ({ minimumScheduleInterval: { value: '30s' } }) }, + } as any; + const service = new DefaultAlertService({} as any, server, {} as any); + expect(service.getMinimumRuleInterval()).toBe('1m'); + }); + + it('returns minimum interval if greater than 1m', () => { + const server = { + alerting: { getConfig: () => ({ minimumScheduleInterval: { value: '5m' } }) }, + } as any; + const service = new DefaultAlertService({} as any, server, {} as any); + expect(service.getMinimumRuleInterval()).toBe('5m'); + }); + }); + + describe('setupStatusRule', () => { + it('creates status rule if enabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultStatusRuleEnabled: true } as any; + service.getSettings = jest.fn().mockResolvedValue({ + defaultStatusRuleEnabled: true, + }); + await service.setupStatusRule(); + expect(service.createDefaultRuleIfNotExist).toHaveBeenCalledWith( + SYNTHETICS_STATUS_RULE, + 'Synthetics status internal rule', + '1m' + ); + }); + + it('does not create status rule if disabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultStatusRuleEnabled: false } as any; + const result = await service.setupStatusRule(); + expect(service.createDefaultRuleIfNotExist).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + }); + + describe('setupTlsRule', () => { + it('creates tls rule if enabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultTlsRuleEnabled: true } as any; + service.getSettings = jest.fn().mockResolvedValue({ + defaultTlsRuleEnabled: true, + }); + await service.setupTlsRule(); + expect(service.createDefaultRuleIfNotExist).toHaveBeenCalledWith( + SYNTHETICS_TLS_RULE, + 'Synthetics internal TLS rule', + '1m' + ); + }); + + it('does not create tls rule if disabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultTLSRuleEnabled: false } as any; + const result = await service.setupTlsRule(); + expect(service.createDefaultRuleIfNotExist).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + }); + + describe('existing alerts', () => { + function setUpExistingRules>( + ruleOverride?: Partial>, + getRulesClientMocks = {} + ) { + const getRulesClient = jest.fn(); + const mockRule: any = ruleOverride ?? { + actions: [{ alertsFilter: { query: { kql: 'some kql', filters: [] } } }], + systemActions: [{ id: 'some system action', actionTypeId: 'actionTypeId', params: {} }], + ruleTypeId: 'xpack.synthetics.alerts.monitorStatus', + alertTypeId: 'xpack.synthetics.alerts.monitorStatus', + id: '123', + }; + const find = jest.fn(); + find.mockResolvedValue({ + data: [mockRule], + }); + getRulesClient.mockReturnValue({ find, ...getRulesClientMocks }); + + return { getRulesClient, mockRule }; + } + + function formatMockRuleResult(mockRule: any) { + return { + ...omit(mockRule, 'systemActions'), + actions: [...mockRule.actions, ...mockRule.systemActions], + }; + } + + afterEach(() => jest.resetAllMocks()); + + describe('getExistingAlert', () => { + it('returns rule if exists', async () => { + const { getRulesClient, mockRule } = setUpExistingRules(); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + const result = await service.getExistingAlert('xpack.synthetics.alerts.monitorStatus'); + expect(result).toEqual(formatMockRuleResult(mockRule)); + }); + + it('returns undefined if rule does not exist', async () => { + const find = jest.fn().mockResolvedValue({ data: [] }); + const getRulesClient = jest.fn(); + getRulesClient.mockReturnValue({ find }); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + const result = await service.getExistingAlert('xpack.synthetics.alerts.monitorStatus'); + expect(result).toBeUndefined(); + }); + }); + describe('createDefaultAlertIfNotExist', () => { + it('returns rule if exists', async () => { + const { getRulesClient, mockRule } = setUpExistingRules(); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + const alert = await service.createDefaultRuleIfNotExist( + 'xpack.synthetics.alerts.monitorStatus', + 'name', + '1m' + ); + expect(alert).toEqual(formatMockRuleResult(mockRule)); + expect(getRulesClient).toHaveBeenCalled(); + }); + + it('creates rule if does not exist', async () => { + const sampleAction = { alertsFilter: { query: { kql: 'some kql', filters: [] } } }; + const find = jest.fn().mockResolvedValue({ data: [] }); + const create = jest.fn().mockResolvedValue({ + actions: [sampleAction], + systemActions: [], + id: '123', + alertTypeId: 'testalertid', + }); + const getActionsClient = jest.fn(); + getActionsClient.mockReturnValue({ + getAll: jest + .fn() + .mockResolvedValue([{ id: 'id', actionTypeId: 'actionTypeId', name: 'action name' }]), + }); + const getRulesClient = jest.fn(); + getRulesClient.mockReturnValue({ find, create }); + const service = new DefaultAlertService( + { actions: { getActionsClient }, alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const result = await service.createDefaultRuleIfNotExist( + 'xpack.synthetics.alerts.monitorStatus', + 'name', + '1m' + ); + expect(result).toEqual({ + actions: [sampleAction], + id: '123', + alertTypeId: 'testalertid', + ruleTypeId: 'testalertid', + }); + }); + }); + + function setUpUpdateTest>(mockRule?: Partial>) { + const update = jest.fn().mockResolvedValue({ + alertTypeId: 'test-alert-type-id', + actions: [{ id: 'id', actionTypeId: 'actionTypeId', name: 'action name' }], + systemActions: [{ id: 'sys-id', actionTypeId: 'actionTypeId', name: 'action name' }], + updatedAlertField: 'value', + }); + const { getRulesClient } = setUpExistingRules(mockRule ?? { schedule: { interval: '1m' } }, { + update, + }); + const getConfig = jest.fn().mockReturnValue({ minimumScheduleInterval: { value: '3m' } }); + const server = { + alerting: { + getConfig, + }, + } as any; + const getActionsClient = jest.fn(); + const getAll = jest + .fn() + .mockResolvedValue([{ id: 'id', actionTypeId: 'actionTypeId', name: 'action name' }]); + getActionsClient.mockReturnValue({ + getAll, + }); + const context = { actions: { getActionsClient }, alerting: { getRulesClient } }; + + return { + context, + server, + mocks: { update, getRulesClient, getConfig, getActionsClient, getAll }, + }; + } + + describe('updateStatusRule', () => { + it('updates the rule if it is enabled', async () => { + const { + context, + server, + mocks: { update, getAll }, + } = setUpUpdateTest({ + id: 'test-alert-id', + name: 'test-alert-name', + tags: ['test-alert-tags'], + schedule: { interval: '1m' }, + params: { param: 'value' }, + }); + const service = new DefaultAlertService(context as any, server as any, {} as any); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const result = await service.updateStatusRule(true); + expect(result).toEqual({ + actions: [ + { actionTypeId: 'actionTypeId', id: 'id', name: 'action name' }, + { actionTypeId: 'actionTypeId', id: 'sys-id', name: 'action name' }, + ], + alertTypeId: 'test-alert-type-id', + ruleTypeId: 'test-alert-type-id', + updatedAlertField: 'value', + }); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0][0]).toEqual({ + data: { + actions: [], + name: 'test-alert-name', + params: { param: 'value' }, + schedule: { interval: '3m' }, + tags: ['test-alert-tags'], + }, + id: 'test-alert-id', + }); + expect(getAll).toHaveBeenCalled(); + }); + + it('deletes the rule if it is disabled', async () => { + const server = { + alerting: { + getConfig: jest.fn().mockReturnValue({ minimumScheduleInterval: { value: '3m' } }), + }, + } as any; + const bulkDeleteRules = jest.fn(); + const { getRulesClient } = setUpExistingRules(undefined, { bulkDeleteRules }); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + server as any, + {} as any + ); + await service.updateStatusRule(false); + expect(bulkDeleteRules).toHaveBeenCalled(); + expect(bulkDeleteRules.mock.calls[0][0]).toEqual({ + filter: + 'alert.attributes.alertTypeId:"xpack.synthetics.alerts.monitorStatus" AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"', + }); + }); + }); + + describe('updateTlsRule', () => { + it('updates the rule if it is enabled', async () => { + const { context, server } = setUpUpdateTest(); + const service = new DefaultAlertService(context as any, server as any, {} as any); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const result = await service.updateTlsRule(true); + expect(result).toEqual({ + actions: [ + { actionTypeId: 'actionTypeId', id: 'id', name: 'action name' }, + { actionTypeId: 'actionTypeId', id: 'sys-id', name: 'action name' }, + ], + alertTypeId: 'test-alert-type-id', + ruleTypeId: 'test-alert-type-id', + updatedAlertField: 'value', + }); + }); + + it('creates the rule if it does not exist', async () => { + const { context, server } = setUpUpdateTest(); + const service = new DefaultAlertService(context as any, server as any, {} as any); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const getExistingAlertMock = jest.fn().mockResolvedValue(undefined); + service.getExistingAlert = getExistingAlertMock; + const createDefaultAlertIfNotExistMock = jest.fn(); + service.createDefaultRuleIfNotExist = createDefaultAlertIfNotExistMock; + const result = await service.updateTlsRule(true); + expect(result).toBeUndefined(); + expect(service.getExistingAlert).toHaveBeenCalled(); + expect(service.createDefaultRuleIfNotExist).toHaveBeenCalled(); + expect(getExistingAlertMock.mock.calls[0][0]).toBe('xpack.synthetics.alerts.tls'); + expect(createDefaultAlertIfNotExistMock.mock.calls[0]).toStrictEqual([ + 'xpack.synthetics.alerts.tls', + 'Synthetics internal TLS rule', + '3m', + ]); + }); + + it('deletes the rule if it is disabled', async () => { + const server = { + alerting: { + getConfig: jest.fn().mockReturnValue({ minimumScheduleInterval: { value: '3m' } }), + }, + } as any; + const bulkDeleteRules = jest.fn(); + const { getRulesClient } = setUpExistingRules(undefined, { bulkDeleteRules }); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + server as any, + {} as any + ); + await service.updateTlsRule(false); + expect(bulkDeleteRules).toHaveBeenCalled(); + expect(bulkDeleteRules.mock.calls[0][0]).toEqual({ + filter: + 'alert.attributes.alertTypeId:"xpack.synthetics.alerts.tls" AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"', + }); + }); + }); + }); + + describe('getActionConnectors', () => { + it('fetches settings if not set', async () => { + const getActionsClient = jest.fn(); + const getAll = jest.fn().mockResolvedValue([{ id: 'id', actionTypeId: 'actionTypeId' }]); + getActionsClient.mockReturnValue({ + getAll, + }); + const service = new DefaultAlertService( + { actions: { getActionsClient } } as any, + {} as any, + { get: jest.fn() } as any + ); + const connectors = await service.getActionConnectors(); + expect(connectors).toEqual({ + actionConnectors: [{ id: 'id', actionTypeId: 'actionTypeId' }], + settings: { + ...DYNAMIC_SETTINGS_DEFAULTS, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }, + }); + expect(getAll).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts index 4b7aeb5eed7e8..2e2263f6e3965 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts @@ -79,7 +79,7 @@ export class DefaultAlertService { if (this.settings?.defaultStatusRuleEnabled === false) { return; } - return this.createDefaultAlertIfNotExist( + return this.createDefaultRuleIfNotExist( SYNTHETICS_STATUS_RULE, `Synthetics status internal rule`, minimumRuleInterval @@ -91,7 +91,7 @@ export class DefaultAlertService { if (this.settings?.defaultTLSRuleEnabled === false) { return; } - return this.createDefaultAlertIfNotExist( + return this.createDefaultRuleIfNotExist( SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, minimumRuleInterval @@ -116,7 +116,7 @@ export class DefaultAlertService { return { ...alert, actions: [...actions, ...systemActions], ruleTypeId: alert.alertTypeId }; } - async createDefaultAlertIfNotExist(ruleType: DefaultRuleType, name: string, interval: string) { + async createDefaultRuleIfNotExist(ruleType: DefaultRuleType, name: string, interval: string) { const alert = await this.getExistingAlert(ruleType); if (alert) { return alert; @@ -152,7 +152,7 @@ export class DefaultAlertService { async updateStatusRule(enabled?: boolean) { const minimumRuleInterval = this.getMinimumRuleInterval(); if (enabled) { - return this.updateDefaultAlert( + return this.upsertDefaultAlert( SYNTHETICS_STATUS_RULE, `Synthetics status internal rule`, minimumRuleInterval @@ -168,7 +168,7 @@ export class DefaultAlertService { async updateTlsRule(enabled?: boolean) { const minimumRuleInterval = this.getMinimumRuleInterval(); if (enabled) { - return this.updateDefaultAlert( + return this.upsertDefaultAlert( SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, minimumRuleInterval @@ -181,7 +181,7 @@ export class DefaultAlertService { } } - async updateDefaultAlert(ruleType: DefaultRuleType, name: string, interval: string) { + async upsertDefaultAlert(ruleType: DefaultRuleType, name: string, interval: string) { const rulesClient = (await this.context.alerting)?.getRulesClient(); const alert = await this.getExistingAlert(ruleType); @@ -213,7 +213,7 @@ export class DefaultAlertService { }; } - return await this.createDefaultAlertIfNotExist(ruleType, name, interval); + return await this.createDefaultRuleIfNotExist(ruleType, name, interval); } async getAlertActions(ruleType: DefaultRuleType) { diff --git a/x-pack/plugins/observability_solution/synthetics/tsconfig.json b/x-pack/plugins/observability_solution/synthetics/tsconfig.json index 8446b7850d3a9..d0822a733baff 100644 --- a/x-pack/plugins/observability_solution/synthetics/tsconfig.json +++ b/x-pack/plugins/observability_solution/synthetics/tsconfig.json @@ -99,7 +99,8 @@ "@kbn/core-application-browser", "@kbn/dashboard-plugin", "@kbn/slo-plugin", - "@kbn/ebt-tools" + "@kbn/ebt-tools", + "@kbn/alerting-types" ], "exclude": ["target/**/*"] }