diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts index ea2dc47a1d7a9..ac7d46c627bd0 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts @@ -15,6 +15,7 @@ import { } from '@kbn/core/server/mocks'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import type { SavedObject } from '@kbn/core-saved-objects-server'; +import type { RawRule } from '../../../../types'; import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; @@ -114,7 +115,9 @@ describe('bulkDisableRules', () => { let actionsClient: jest.Mocked; const mockCreatePointInTimeFinderAsInternalUser = ( - response = { saved_objects: [enabledRule1, enabledRule2] } + response: { saved_objects: Array>> } = { + saved_objects: [enabledRule1, enabledRule2], + } ) => { encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest .fn() @@ -555,6 +558,109 @@ describe('bulkDisableRules', () => { }); }); + describe('lastRun outcome message migration', () => { + test('migrates legacy string lastRun.outcomeMsg to string[] when bulk disabling', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...enabledRule1, + attributes: { + ...enabledRule1.attributes, + lastRun: { + outcome: 'failed', + // @ts-expect-error test legacy outcomeMsg migration + outcomeMsg: 'legacy message', + }, + }, + }, + enabledRule2, + ], + }); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [disabledRuleForBulkDisable1, disabledRuleForBulkDisable2], + }); + + await rulesClient.bulkDisableRules({ filter: 'fake_filter' }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'id1', + attributes: expect.objectContaining({ + lastRun: { + outcome: 'failed', + outcomeMsg: ['legacy message'], + }, + }), + }), + ]), + { overwrite: true } + ); + }); + + test('leaves lastRun unchanged when outcomeMsg is already a string array', async () => { + const lastRun = { + outcome: 'succeeded' as const, + outcomeMsg: ['msg a', 'msg b'], + alertsCount: { + new: 0, + ignored: 0, + recovered: 0, + active: 0, + }, + }; + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...enabledRule1, + attributes: { + ...enabledRule1.attributes, + lastRun, + }, + }, + ], + }); + mockUnsecuredSavedObjectFind(1); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [disabledRuleForBulkDisable1], + }); + + await rulesClient.bulkDisableRules({ filter: 'fake_filter' }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'id1', + attributes: expect.objectContaining({ + lastRun, + }), + }), + ]), + { overwrite: true } + ); + }); + + test('does not add lastRun when the rule has no lastRun', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [enabledRule1], + }); + mockUnsecuredSavedObjectFind(1); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [disabledRuleForBulkDisable1], + }); + + await rulesClient.bulkDisableRules({ filter: 'fake_filter' }); + + const bulkCreateObjects = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array<{ + id: string; + attributes: Record; + }>; + const attributesForRule = bulkCreateObjects.find((o) => o.id === 'id1')?.attributes; + expect(attributesForRule).toBeDefined(); + expect(attributesForRule).not.toHaveProperty('lastRun'); + }); + }); + describe('taskManager', () => { test('should call task manager bulkDisable', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts index b6d078c4753ae..146c4962153a7 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts @@ -27,6 +27,7 @@ import { untrackRuleAlerts, updateMeta, bulkMigrateLegacyActions, + migrateLegacyLastRunOutcomeMsg, } from '../../../../rules_client/lib'; import { transformRuleAttributesToRuleDomain, transformRuleDomainToRule } from '../../transforms'; import type { @@ -180,6 +181,9 @@ const bulkDisableRulesWithOCC = async ( : null, updatedBy: username, updatedAt: new Date().toISOString(), + ...(castedAttributes.lastRun + ? { lastRun: migrateLegacyLastRunOutcomeMsg(castedAttributes.lastRun) } + : {}), }); rulesToDisable.push({ diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.test.ts index 0bd0e39761c34..42e12b1e8a574 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.test.ts @@ -13,6 +13,8 @@ import { savedObjectsRepositoryMock, uiSettingsServiceMock, } from '@kbn/core/server/mocks'; +import type { SavedObject } from '@kbn/core/server'; +import type { RawRule } from '../../../../types'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; @@ -118,7 +120,9 @@ describe('bulkEnableRules', () => { let actionsClient: jest.Mocked; const mockCreatePointInTimeFinderAsInternalUser = ( - response = { saved_objects: [disabledRule1, disabledRule2] } + response: { saved_objects: Array>> } = { + saved_objects: [disabledRule1, disabledRule2], + } ) => { encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest .fn() @@ -675,6 +679,110 @@ describe('bulkEnableRules', () => { }); }); + describe('lastRun outcome message migration', () => { + test('migrates legacy string lastRun.outcomeMsg to string[] when bulk enabling', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...disabledRule1, + attributes: { + ...disabledRule1.attributes, + lastRun: { + outcome: 'failed', + // @ts-expect-error test legacy outcomeMsg migration + outcomeMsg: 'legacy message', + }, + }, + }, + disabledRule2, + ], + }); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], + }); + + await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'id1', + attributes: expect.objectContaining({ + lastRun: { + outcome: 'failed', + outcomeMsg: ['legacy message'], + }, + }), + }), + ]), + { overwrite: true } + ); + }); + + test('leaves lastRun unchanged when outcomeMsg is already a string array', async () => { + const lastRun = { + outcome: 'succeeded' as const, + outcomeMsg: ['msg a', 'msg b'], + alertsCount: { + new: 0, + ignored: 0, + recovered: 0, + active: 0, + }, + }; + + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...disabledRule1, + attributes: { + ...disabledRule1.attributes, + lastRun, + }, + }, + ], + }); + mockUnsecuredSavedObjectFind(1); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [enabledRuleForBulkOps1], + }); + + await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'id1', + attributes: expect.objectContaining({ + lastRun, + }), + }), + ]), + { overwrite: true } + ); + }); + + test('does not add lastRun when the rule has no lastRun', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [disabledRule1], + }); + mockUnsecuredSavedObjectFind(1); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [enabledRuleForBulkOps1], + }); + + await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); + + const bulkCreateObjects = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array<{ + id: string; + attributes: Record; + }>; + const attributesForRule = bulkCreateObjects.find((o) => o.id === 'id1')?.attributes; + expect(attributesForRule).toBeDefined(); + expect(attributesForRule).not.toHaveProperty('lastRun'); + }); + }); + describe('taskManager', () => { test('should return task id if enabling task failed', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.ts index 285f3d470f668..3d01aa689d682 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/bulk_enable/bulk_enable_rules.ts @@ -37,6 +37,7 @@ import { createNewAPIKeySet, updateMetaAttributes, bulkMigrateLegacyActions, + migrateLegacyLastRunOutcomeMsg, } from '../../../../rules_client/lib'; import type { RulesClientContext, BulkOperationError } from '../../../../rules_client/types'; import { validateScheduleLimit } from '../get_schedule_frequency'; @@ -257,6 +258,9 @@ const bulkEnableRulesWithOCC = async ( warning: null, }, scheduledTaskId: rule.id, + ...(rule.attributes.lastRun + ? { lastRun: migrateLegacyLastRunOutcomeMsg(rule.attributes.lastRun) } + : {}), }); const shouldScheduleTask = await getShouldScheduleTask( diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts index 38039969c9c78..ba7d8b2c41ec3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/update/update_rule.ts @@ -29,6 +29,7 @@ import { createNewAPIKeySet, updateMetaAttributes, bulkMigrateLegacyActions, + migrateLegacyLastRunOutcomeMsg, } from '../../../../rules_client/lib'; import type { RuleParams } from '../../types'; import type { UpdateRuleData } from './types'; @@ -415,25 +416,3 @@ async function updateRuleAttributes({ // without fixing all of other solution types return rule as SanitizedRule; } - -/** - * Migrates legacy lastRun.outcomeMsg from string to string[] - * - * Rule SO schema forces lastRun.outcomeMsg to be string[]. - * However, some rules may have lastRun.outcomeMsg as string after upgrading from 7.x due to - * lack of migration. lastRun.outcomeMsg schema change from string to string[] happened after - * classical migrations were deprecated due to Serverless. And quite often it's not an issue - * as lastRun is absent. - */ -function migrateLegacyLastRunOutcomeMsg( - lastRun: LastRun -): LastRun { - if (typeof lastRun.outcomeMsg === 'string') { - return { - ...lastRun, - outcomeMsg: [lastRun.outcomeMsg], - }; - } - - return lastRun; -} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts index bf1c05376fd61..84d10b6143801 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts @@ -9,7 +9,7 @@ import { RecoveredActionGroup } from '../../../../common'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { transformRuleAttributesToRuleDomain } from './transform_rule_attributes_to_rule_domain'; import type { UntypedNormalizedRuleType } from '../../../rule_type_registry'; -import type { RawRuleAction } from '../../../types'; +import type { RawRuleAction, RawRuleLastRun } from '../../../types'; const ruleType: jest.Mocked = { id: 'test.rule-type', @@ -240,4 +240,97 @@ describe('transformRuleAttributesToRuleDomain', () => { } `); }); + + describe('lastRun outcomeMsg migration', () => { + const references = [{ name: 'default-action-ref', type: 'action', id: 'default-action-id' }]; + + const baseRule = { + enabled: false, + tags: ['foo'], + createdBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + legacyId: null, + muteAll: false, + mutedInstanceIds: [], + snoozeSchedule: [], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending' as const, + error: null, + warning: null, + }, + params: {}, + throttle: null, + notifyWhen: null, + actions: [defaultAction, systemAction], + name: 'my rule name', + revision: 0, + updatedBy: 'user', + apiKey: MOCK_API_KEY, + apiKeyOwner: 'user', + flapping: { + lookBackWindow: 20, + statusChangeThreshold: 20, + }, + }; + + const transform = (rule: typeof baseRule & { lastRun?: RawRuleLastRun }) => + transformRuleAttributesToRuleDomain( + rule, + { + id: '1', + logger, + ruleType, + references, + }, + isSystemAction + ); + + it('migrates legacy string lastRun.outcomeMsg to string[]', () => { + const res = transform({ + ...baseRule, + lastRun: { + outcome: 'failed' as const, + // @ts-expect-error test outcomeMsg migration + outcomeMsg: 'legacy message', + }, + }); + + expect(res.lastRun).toEqual({ + outcome: 'failed', + outcomeMsg: ['legacy message'], + }); + }); + + it('preserves lastRun when outcomeMsg is already a string array', () => { + const lastRun = { + outcome: 'succeeded' as const, + outcomeMsg: ['a', 'b'], + alertsCount: { + new: 0, + ignored: 0, + recovered: 0, + active: 0, + }, + }; + + const res = transform({ + ...baseRule, + lastRun, + }); + + expect(res.lastRun).toBe(lastRun); + }); + + it('omits lastRun when the raw rule has no lastRun', () => { + const res = transform(baseRule); + + expect(res).not.toHaveProperty('lastRun'); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts index d20a9618d342a..bbf6d57b2bacc 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts @@ -7,6 +7,7 @@ import { isEmpty } from 'lodash'; import type { Logger } from '@kbn/core/server'; import type { SavedObjectReference } from '@kbn/core/server'; +import { migrateLegacyLastRunOutcomeMsg } from '../../../rules_client/lib'; import { ruleExecutionStatusValues } from '../constants'; import { getRuleSnoozeEndTime } from '../../../lib'; import type { RuleDomain, Monitoring, RuleParams } from '../types'; @@ -225,16 +226,7 @@ export const transformRuleAttributesToRuleDomain = { + it('wraps a string outcomeMsg in an array', () => { + const lastRun = { outcomeMsg: 'legacy message' as unknown }; + + expect(migrateLegacyLastRunOutcomeMsg(lastRun)).toEqual({ + outcomeMsg: ['legacy message'], + }); + }); + + it('preserves other lastRun fields when wrapping a string outcomeMsg', () => { + const lastRun = { + outcome: 'succeeded' as const, + outcomeMsg: 'only string' as unknown, + }; + + expect(migrateLegacyLastRunOutcomeMsg(lastRun)).toEqual({ + outcome: 'succeeded', + outcomeMsg: ['only string'], + }); + }); + + it('returns lastRun unchanged when outcomeMsg is already a string array', () => { + const lastRun = { outcomeMsg: ['a', 'b'] }; + + expect(migrateLegacyLastRunOutcomeMsg(lastRun)).toBe(lastRun); + }); + + it('returns lastRun unchanged when outcomeMsg is undefined', () => { + const lastRun = { outcome: 'failed' as const }; + + // @ts-expect-error test with lastRun.outcomeMsg = undefined + expect(migrateLegacyLastRunOutcomeMsg(lastRun)).toBe(lastRun); + }); + + it('returns lastRun unchanged when outcomeMsg is null', () => { + const lastRun = { outcomeMsg: null }; + + expect(migrateLegacyLastRunOutcomeMsg(lastRun)).toBe(lastRun); + }); + + it('returns lastRun unchanged when outcomeMsg is a non-string type', () => { + const lastRun = { outcomeMsg: 42 as unknown }; + + expect(migrateLegacyLastRunOutcomeMsg(lastRun)).toBe(lastRun); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/migrate_legacy_last_run_outcome_message.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/migrate_legacy_last_run_outcome_message.ts new file mode 100644 index 0000000000000..5aeb833a524d2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/lib/migrate_legacy_last_run_outcome_message.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Migrates legacy lastRun.outcomeMsg from string to string[] + * + * Rule SO schema forces lastRun.outcomeMsg to be string[]. + * However, some rules may have lastRun.outcomeMsg as string after upgrading from 7.x due to + * lack of migration. lastRun.outcomeMsg schema change from string to string[] happened after + * classical migrations were deprecated due to Serverless. And quite often it's not an issue + * as lastRun is absent. + */ +export function migrateLegacyLastRunOutcomeMsg( + lastRun: LastRun +): LastRun { + if (typeof lastRun.outcomeMsg === 'string') { + return { + ...lastRun, + outcomeMsg: [lastRun.outcomeMsg], + }; + } + + return lastRun; +} diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts index 616dd4beb82ba..05ea56dfddc95 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/tests/test_helpers.ts @@ -37,7 +37,7 @@ export const defaultRule = { consumer: 'fakeConsumer', alertTypeId: 'fakeType', schedule: { interval: '5m' }, - actions: [] as unknown, + actions: [], }, references: [], version: '1', @@ -56,13 +56,15 @@ export const defaultRuleForBulkDelete = { consumer: 'fakeConsumer', alertTypeId: 'fakeType', schedule: { interval: '5m' }, - actions: [] as unknown, + actions: [], executionStatus: { - lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), - status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.000Z', + status: 'pending' as const, + error: null, + warning: null, }, - createdAt: new Date('2019-02-12T21:01:22.000Z'), - updatedAt: new Date('2019-02-12T21:01:22.000Z'), + createdAt: '2019-02-12T21:01:22.000Z', + updatedAt: '2019-02-12T21:01:22.000Z', }, references: [], version: '1', @@ -94,11 +96,13 @@ export const enabledRule1 = { scheduledTaskId: 'id1', apiKey: Buffer.from('123:abc').toString('base64'), executionStatus: { - lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), - status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.000Z', + status: 'pending' as const, + error: null, + warning: null, }, - createdAt: new Date('2019-02-12T21:01:22.000Z'), - updatedAt: new Date('2019-02-12T21:01:22.000Z'), + createdAt: '2019-02-12T21:01:22.000Z', + updatedAt: '2019-02-12T21:01:22.000Z', }, }; @@ -111,11 +115,13 @@ export const enabledRule2 = { scheduledTaskId: 'id2', apiKey: Buffer.from('321:abc').toString('base64'), executionStatus: { - lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), - status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.000Z', + status: 'pending' as const, + error: null, + warning: null, }, - createdAt: new Date('2019-02-12T21:01:22.000Z'), - updatedAt: new Date('2019-02-12T21:01:22.000Z'), + createdAt: '2019-02-12T21:01:22.000Z', + updatedAt: '2019-02-12T21:01:22.000Z', }, }; @@ -308,8 +314,10 @@ export const disabledRule1 = { scheduledTaskId: 'id1', apiKey: Buffer.from('123:abc').toString('base64'), executionStatus: { - lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), - status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.000Z', + status: 'pending' as const, + error: null, + warning: null, }, }, }; @@ -323,8 +331,10 @@ export const disabledRule2 = { scheduledTaskId: 'id2', apiKey: Buffer.from('321:abc').toString('base64'), executionStatus: { - lastExecutionDate: new Date('2019-02-12T21:01:22.000Z'), - status: 'pending', + lastExecutionDate: '2019-02-12T21:01:22.000Z', + status: 'pending' as const, + error: null, + warning: null, }, }, }; @@ -335,6 +345,7 @@ export const disabledRuleWithAction1 = { ...disabledRule1.attributes, actions: [ { + uuid: 'test-uuid-1', group: 'default', actionTypeId: '1', actionRef: '1', @@ -352,6 +363,7 @@ export const disabledRuleWithAction2 = { ...disabledRule2.attributes, actions: [ { + uuid: 'test-uuid-2', group: 'default', actionTypeId: '1', actionRef: '1', diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts index 0c2f441a199d9..f3a24b3613f70 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts @@ -26,6 +26,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./unsnooze_internal')); loadTestFile(require.resolve('./bulk_edit')); loadTestFile(require.resolve('./bulk_disable')); + loadTestFile(require.resolve('./last_run_outcome_msg_migration')); loadTestFile(require.resolve('./capped_action_type')); loadTestFile(require.resolve('./scheduled_task_id')); loadTestFile(require.resolve('./run_soon')); diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/last_run_outcome_msg_migration.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/last_run_outcome_msg_migration.ts new file mode 100644 index 0000000000000..e2123d8eed96e --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/last_run_outcome_msg_migration.ts @@ -0,0 +1,152 @@ +/* + * 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 expect from '@kbn/expect'; +import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import type { RawRule } from '@kbn/alerting-plugin/server/types'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { Spaces } from '../../../scenarios'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getUrlPrefix, getTestRuleData, ObjectRemover, checkAAD } from '../../../../common/lib'; + +/** Simulates 7.x documents where lastRun.outcomeMsg was stored as a string. */ +const LEGACY_OUTCOME_MSG = 'legacy outcome message string'; + +export default function lastRunOutcomeMsgMigrationTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('lastRun outcomeMsg migration', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(async () => { + await objectRemover.removeAll(); + }); + + async function getAlertFromEs(ruleId: string): Promise { + const response = await es.get<{ alert: RawRule }>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${ruleId}`, + }, + { meta: true } + ); + expect(response.statusCode).to.eql(200); + return response.body._source!.alert; + } + + async function injectLegacyStringOutcomeMsg(ruleId: string) { + await es.update({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${ruleId}`, + doc: { + alert: { + lastRun: { + outcome: 'failed', + outcomeMsg: LEGACY_OUTCOME_MSG, + }, + }, + }, + refresh: 'wait_for', + }); + } + + it('migrates legacy string outcomeMsg when bulk disabling rules', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: true, + schedule: { interval: '1d' }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await injectLegacyStringOutcomeMsg(createdRule.id); + + const beforeBulk = await getAlertFromEs(createdRule.id); + expect((beforeBulk.lastRun as { outcomeMsg?: unknown } | undefined)?.outcomeMsg).to.eql( + LEGACY_OUTCOME_MSG + ); + + await supertest + .patch(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_disable`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + }) + .expect(200); + + const afterBulk = await getAlertFromEs(createdRule.id); + expect(afterBulk.lastRun?.outcomeMsg).to.eql([LEGACY_OUTCOME_MSG]); + expect(afterBulk.enabled).to.eql(false); + + const { body: ruleFromApi } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + + expect(ruleFromApi.last_run?.outcome_msg).to.eql([LEGACY_OUTCOME_MSG]); + + await checkAAD({ + supertest, + spaceId: Spaces.space1.id, + type: RULE_SAVED_OBJECT_TYPE, + id: createdRule.id, + }); + }); + + it('migrates legacy string outcomeMsg when bulk enabling rules', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + schedule: { interval: '1d' }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await injectLegacyStringOutcomeMsg(createdRule.id); + + const beforeBulk = await getAlertFromEs(createdRule.id); + expect((beforeBulk.lastRun as { outcomeMsg?: unknown } | undefined)?.outcomeMsg).to.eql( + LEGACY_OUTCOME_MSG + ); + + await supertest + .patch(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_enable`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + }) + .expect(200); + + const afterBulk = await getAlertFromEs(createdRule.id); + expect(afterBulk.lastRun?.outcomeMsg).to.eql([LEGACY_OUTCOME_MSG]); + expect(afterBulk.enabled).to.eql(true); + + const { body: ruleFromApi } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + + expect(ruleFromApi.last_run?.outcome_msg).to.eql([LEGACY_OUTCOME_MSG]); + + await checkAAD({ + supertest, + spaceId: Spaces.space1.id, + type: RULE_SAVED_OBJECT_TYPE, + id: createdRule.id, + }); + }); + }); +}