diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 2b92e8caa7ef9..6643f8d0ec870 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -65,10 +65,9 @@ Rules and connectors are isolated to the {kib} space in which they were created. Rules are authorized using an <> associated with the last user to edit the rule. This API key captures a snapshot of the user's privileges at the time of edit and is subsequently used to run all background tasks associated with the rule, including condition checks like {es} queries and triggered actions. The following rule actions will re-generate the API key: * Creating a rule -* Enabling a disabled rule * Updating a rule [IMPORTANT] ============================================== -If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates, disables, or re-enables the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. +If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. ============================================== diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 75398a6668755..ec01c2c15abf4 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -1828,7 +1828,7 @@ export class RulesClient { } private async enableWithOCC({ id }: { id: string }) { - let apiKeyToInvalidate: string | null = null; + let existingApiKey: string | null = null; let attributes: RawRule; let version: string | undefined; @@ -1837,14 +1837,11 @@ export class RulesClient { await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); - apiKeyToInvalidate = decryptedAlert.attributes.apiKey; + existingApiKey = decryptedAlert.attributes.apiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { - // We'll skip invalidating the API key since we failed to load the decrypted saved object - this.logger.error( - `enable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` - ); + this.logger.error(`enable(): Failed to load API key of alert ${id}: ${e.message}`); // Still attempt to load the attributes and version using SOC const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; @@ -1886,19 +1883,10 @@ export class RulesClient { if (attributes.enabled === false) { const username = await this.getUserName(); - let createdAPIKey = null; - try { - createdAPIKey = await this.createAPIKey( - this.generateAPIKeyName(attributes.alertTypeId, attributes.name) - ); - } catch (error) { - throw Boom.badRequest(`Error enabling rule: could not create API key - ${error.message}`); - } - const updateAttributes = this.updateMeta({ ...attributes, + ...(!existingApiKey && (await this.createNewAPIKeySet({ attributes, username }))), enabled: true, - ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedBy: username, updatedAt: new Date().toISOString(), executionStatus: { @@ -1909,15 +1897,10 @@ export class RulesClient { warning: null, }, }); + try { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { - // Avoid unused API key - await bulkMarkApiKeysForInvalidation( - { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, - this.logger, - this.unsecuredSavedObjectsClient - ); throw e; } const scheduledTask = await this.scheduleRule({ @@ -1930,16 +1913,28 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, { scheduledTaskId: scheduledTask.id, }); - if (apiKeyToInvalidate) { - await bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, - this.logger, - this.unsecuredSavedObjectsClient - ); - } } } + private async createNewAPIKeySet({ + attributes, + username, + }: { + attributes: RawRule; + username: string | null; + }): Promise> { + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest(`Error creating API key for rule: ${error.message}`); + } + + return this.apiKeyAsAlertAttributes(createdAPIKey, username); + } + public async disable({ id }: { id: string }): Promise { return await retryIfConflicts( this.logger, @@ -1949,7 +1944,6 @@ export class RulesClient { } private async disableWithOCC({ id }: { id: string }) { - let apiKeyToInvalidate: string | null = null; let attributes: RawRule; let version: string | undefined; @@ -1958,14 +1952,10 @@ export class RulesClient { await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); - apiKeyToInvalidate = decryptedAlert.attributes.apiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { - // We'll skip invalidating the API key since we failed to load the decrypted saved object - this.logger.error( - `disable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` - ); + this.logger.error(`disable(): Failed to load API key of alert ${id}: ${e.message}`); // Still attempt to load the attributes and version using SOC const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; @@ -2058,26 +2048,14 @@ export class RulesClient { ...attributes, enabled: false, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }), { version } ); - - await Promise.all([ - attributes.scheduledTaskId - ? this.taskManager.removeIfExists(attributes.scheduledTaskId) - : null, - apiKeyToInvalidate - ? await bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, - this.logger, - this.unsecuredSavedObjectsClient - ) - : null, - ]); + if (attributes.scheduledTaskId) { + await this.taskManager.removeIfExists(attributes.scheduledTaskId); + } } } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 02f2c66a491ad..a193733aff26f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -18,7 +18,6 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -111,6 +110,7 @@ describe('disable()', () => { attributes: { ...existingAlert.attributes, apiKey: Buffer.from('123:abc').toString('base64'), + apiKeyOwner: 'elastic', }, version: '123', references: [], @@ -206,11 +206,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -230,12 +230,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); }); test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => { @@ -282,11 +276,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -306,12 +300,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({ @@ -369,11 +357,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -393,12 +381,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(0); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( @@ -408,7 +390,6 @@ describe('disable()', () => { test('falls back when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -422,12 +403,7 @@ describe('disable()', () => { schedule: { interval: '10s' }, alertTypeId: 'myType', enabled: false, - meta: { - versionApiKeyLastmodified: kibanaVersion, - }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -447,7 +423,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test(`doesn't disable already disabled alerts`, async () => { @@ -463,14 +438,6 @@ describe('disable()', () => { await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.removeIfExists).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); - }); - - test(`doesn't invalidate when no API key is used`, async () => { - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert); - - await rulesClient.disable({ id: '1' }); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('swallows error when failing to load decrypted saved object', async () => { @@ -479,9 +446,8 @@ describe('disable()', () => { await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); expect(taskManager.removeIfExists).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'disable(): Failed to load API key to invalidate on alert 1: Fail' + 'disable(): Failed to load API key of alert 1: Fail' ); }); @@ -493,17 +459,6 @@ describe('disable()', () => { ); }); - test('swallows error when invalidate API key throws', async () => { - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - await rulesClient.disable({ id: '1' }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); - }); - test('throws when failing to remove task from task manager', async () => { taskManager.removeIfExists.mockRejectedValueOnce(new Error('Failed to remove task')); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index d823e0aaafdb8..8923031ab6b87 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -17,7 +17,6 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -51,23 +50,22 @@ const rulesClientParams: jest.Mocked = { auditLogger, }; -beforeEach(() => { - getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); - (auditLogger.log as jest.Mock).mockClear(); -}); - setGlobalDate(); describe('enable()', () => { let rulesClient: RulesClient; - const existingAlert = { + + const existingRule = { id: '1', type: 'alert', attributes: { + name: 'name', consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', enabled: false, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', @@ -84,23 +82,24 @@ describe('enable()', () => { references: [], }; + const existingRuleWithoutApiKey = { + ...existingRule, + attributes: { + ...existingRule.attributes, + apiKey: null, + apiKeyOwner: null, + }, + }; + beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); rulesClient = new RulesClient(rulesClientParams); - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingAlert); - unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRule); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingRule); rulesClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - enabled: true, - apiKey: null, - apiKeyOwner: null, - updatedBy: 'elastic', - }, - }); taskManager.schedule.mockResolvedValue({ id: '1', scheduledAt: new Date(), @@ -187,27 +186,17 @@ describe('enable()', () => { }); test('enables a rule', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - enabled: true, - apiKey: null, - apiKeyOwner: null, - updatedBy: 'elastic', - }, - }); - await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -217,8 +206,8 @@ describe('enable()', () => { }, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', @@ -265,33 +254,65 @@ describe('enable()', () => { }); }); - test('invalidates API key if ever one existed prior to updating', async () => { - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - apiKey: Buffer.from('123:abc').toString('base64'), - }, + test('enables a rule that does not have an apiKey', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey); + rulesClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '123', name: '123', api_key: 'abc' }, }); - await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); + expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/name'); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( + 'alert', + '1', + { + name: 'name', + schedule: { interval: '10s' }, + alertTypeId: 'myType', + consumer: 'myApp', + enabled: true, + meta: { + versionApiKeyLastmodified: kibanaVersion, + }, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + executionStatus: { + status: 'pending', + lastDuration: 0, + lastExecutionDate: '2019-02-12T21:01:22.479Z', + error: null, + warning: null, + }, + }, + { + version: '123', + } ); }); test(`doesn't enable already enabled alerts`, async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, }, }); @@ -314,6 +335,7 @@ describe('enable()', () => { 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -351,14 +373,14 @@ describe('enable()', () => { }); test('throws an error if API key creation throws', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey); + rulesClientParams.createAPIKey.mockImplementation(() => { throw new Error('no'); }); - expect( + await expect( async () => await rulesClient.enable({ id: '1' }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error enabling rule: could not create API key - no"` - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Error creating API key for rule: no"`); }); test('falls back when failing to getDecryptedAsInternalUser', async () => { @@ -367,7 +389,7 @@ describe('enable()', () => { await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'enable(): Failed to load API key to invalidate on alert 1: Fail' + 'enable(): Failed to load API key of alert 1: Fail' ); }); @@ -396,13 +418,6 @@ describe('enable()', () => { `"Fail to update"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); expect(taskManager.schedule).not.toHaveBeenCalled(); }); @@ -410,9 +425,9 @@ describe('enable()', () => { test('throws error when failing to update the second time', async () => { unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, }, }); @@ -424,7 +439,6 @@ describe('enable()', () => { `"Fail to update second time"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(2); expect(taskManager.schedule).toHaveBeenCalled(); }); @@ -436,15 +450,14 @@ describe('enable()', () => { `"Fail to schedule"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); }); test('enables a rule if conflict errors received when scheduling a task', async () => { unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, apiKey: null, apiKeyOwner: null, @@ -460,11 +473,12 @@ describe('enable()', () => { expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -474,8 +488,8 @@ describe('enable()', () => { }, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index 7626cc49a5c4c..b8a11cc5892cd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -54,7 +54,7 @@ export const DeleteModalConfirmation = ({ 'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText', { defaultMessage: - "You can't recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.", + "You won't be able to recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.", values: { numIdsToDelete, singleTitle, multipleTitle }, } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx new file mode 100644 index 0000000000000..93845ae3b366c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/update_api_key_modal_confirmation.tsx @@ -0,0 +1,95 @@ +/* + * 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 { EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; +import { HttpSetup } from '@kbn/core/public'; +import { useKibana } from '../../common/lib/kibana'; +export const UpdateApiKeyModalConfirmation = ({ + onCancel, + idsToUpdate, + apiUpdateApiKeyCall, + setIsLoadingState, + onUpdated, +}: { + onCancel: () => void; + idsToUpdate: string[]; + apiUpdateApiKeyCall: ({ id, http }: { id: string; http: HttpSetup }) => Promise; + setIsLoadingState: (isLoading: boolean) => void; + onUpdated: () => void; +}) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const [updateModalFlyoutVisible, setUpdateModalVisibility] = useState(false); + + useEffect(() => { + setUpdateModalVisibility(idsToUpdate.length > 0); + }, [idsToUpdate]); + + return updateModalFlyoutVisible ? ( + { + setUpdateModalVisibility(false); + onCancel(); + }} + onConfirm={async () => { + setUpdateModalVisibility(false); + setIsLoadingState(true); + try { + await Promise.all(idsToUpdate.map((id) => apiUpdateApiKeyCall({ id, http }))); + toasts.addSuccess( + i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.successMessage', { + defaultMessage: + 'API {idsToUpdate, plural, one {key} other {keys}} {idsToUpdate, plural, one {has} other {have}} been updated', + values: { idsToUpdate: idsToUpdate.length }, + }) + ); + } catch (e) { + toasts.addError(e, { + title: i18n.translate( + 'xpack.triggersActionsUI.updateApiKeyConfirmModal.failureMessage', + { + defaultMessage: + 'Failed to update the API {idsToUpdate, plural, one {key} other {keys}}', + values: { idsToUpdate: idsToUpdate.length }, + } + ), + }); + } + setIsLoadingState(false); + onUpdated(); + }} + cancelButtonText={i18n.translate( + 'xpack.triggersActionsUI.updateApiKeyConfirmModal.cancelButton', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.triggersActionsUI.updateApiKeyConfirmModal.confirmButton', + { + defaultMessage: 'Update', + } + )} + > + {i18n.translate('xpack.triggersActionsUI.updateApiKeyConfirmModal.description', { + defaultMessage: + 'You will not be able to recover the old API {idsToUpdate, plural, one {key} other {keys}}', + values: { idsToUpdate: idsToUpdate.length }, + })} + + ) : null; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index c9834dd140ea4..d0e7728498c5b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -27,3 +27,4 @@ export { updateRule } from './update'; export { resolveRule } from './resolve_rule'; export { snoozeRule } from './snooze'; export { unsnoozeRule } from './unsnooze'; +export { updateAPIKey } from './update_api_key'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.ts new file mode 100644 index 0000000000000..15d1e6a58596e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { updateAPIKey } from './update_api_key'; + +const http = httpServiceMock.createStartContract(); +beforeEach(() => jest.resetAllMocks()); + +describe('updateAPIKey', () => { + test('should call _update_api_key rule API', async () => { + const result = await updateAPIKey({ http, id: '1/' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/internal/alerting/rule/1%2F/_update_api_key", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts new file mode 100644 index 0000000000000..bc10217d441a2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_api_key.ts @@ -0,0 +1,14 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; + +export async function updateAPIKey({ id, http }: { id: string; http: HttpSetup }): Promise { + return http.post( + `${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(id)}/_update_api_key` + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss new file mode 100644 index 0000000000000..f776a67fabf89 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popopver.scss @@ -0,0 +1,3 @@ +.ruleActionsPopover__deleteButton { + color: $euiColorDangerText; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx new file mode 100644 index 0000000000000..bec45767bfee2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.test.tsx @@ -0,0 +1,201 @@ +/* + * 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 { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { RuleActionsPopover } from './rule_actions_popover'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { Rule } from '../../../..'; + +describe('rule_actions_popover', () => { + const onDeleteMock = jest.fn(); + const onApiKeyUpdateMock = jest.fn(); + const onEnableDisableMock = jest.fn(); + + function mockRule(overloads: Partial = {}): Rule { + return { + id: '12345', + enabled: true, + name: `rule-12345`, + tags: [], + ruleTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + ...overloads, + }; + } + + it('renders all the buttons', () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + expect(screen.getByText('Update API key')).toBeInTheDocument(); + expect(screen.getByText('Delete rule')).toBeInTheDocument(); + expect(screen.getByText('Disable')).toBeInTheDocument(); + }); + + it('calls onDelete', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const deleteButton = screen.getByText('Delete rule'); + expect(deleteButton).toBeInTheDocument(); + fireEvent.click(deleteButton); + + expect(onDeleteMock).toHaveBeenCalledWith('12345'); + await waitFor(() => { + expect(screen.queryByText('Delete rule')).not.toBeInTheDocument(); + }); + }); + + it('disables the rule', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const disableButton = screen.getByText('Disable'); + expect(disableButton).toBeInTheDocument(); + fireEvent.click(disableButton); + + expect(onEnableDisableMock).toHaveBeenCalledWith(false); + await waitFor(() => { + expect(screen.queryByText('Disable')).not.toBeInTheDocument(); + }); + }); + it('enables the rule', async () => { + const rule = mockRule({ enabled: false }); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const enableButton = screen.getByText('Enable'); + expect(enableButton).toBeInTheDocument(); + fireEvent.click(enableButton); + + expect(onEnableDisableMock).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(screen.queryByText('Disable')).not.toBeInTheDocument(); + }); + }); + + it('calls onApiKeyUpdate', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + const deleteButton = screen.getByText('Update API key'); + expect(deleteButton).toBeInTheDocument(); + fireEvent.click(deleteButton); + + expect(onApiKeyUpdateMock).toHaveBeenCalledWith('12345'); + await waitFor(() => { + expect(screen.queryByText('Update API key')).not.toBeInTheDocument(); + }); + }); + + it('disables buttons when the user does not have enough permission', async () => { + const rule = mockRule(); + render( + + + + ); + + const actionButton = screen.getByTestId('ruleActionsButton'); + expect(actionButton).toBeInTheDocument(); + fireEvent.click(actionButton); + + expect(screen.getByText('Delete rule').closest('button')).toBeDisabled(); + expect(screen.getByText('Update API key').closest('button')).toBeDisabled(); + expect(screen.getByText('Disable').closest('button')).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx new file mode 100644 index 0000000000000..0862bf3bf3c1c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions_popover.tsx @@ -0,0 +1,107 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import './rule_actions_popopver.scss'; +import { Rule } from '../../../..'; + +export interface RuleActionsPopoverProps { + rule: Rule; + canSaveRule: boolean; + onDelete: (ruleId: string) => void; + onApiKeyUpdate: (ruleId: string) => void; + onEnableDisable: (enable: boolean) => void; +} + +export const RuleActionsPopover: React.FunctionComponent = ({ + rule, + canSaveRule, + onDelete, + onApiKeyUpdate, + onEnableDisable, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(!isPopoverOpen)} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.popoverButtonTitle', + { defaultMessage: 'Actions' } + )} + /> + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + ownFocus + panelPaddingSize="none" + > + { + setIsPopoverOpen(false); + onEnableDisable(!rule.enabled); + }, + name: !rule.enabled + ? i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.enableRuleButtonLabel', + { defaultMessage: 'Enable' } + ) + : i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.disableRuleButtonLabel', + { defaultMessage: 'Disable' } + ), + }, + { + disabled: !canSaveRule, + 'data-test-subj': 'updateAPIKeyButton', + onClick: () => { + setIsPopoverOpen(false); + onApiKeyUpdate(rule.id); + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.updateAPIKeyButtonLabel', + { defaultMessage: 'Update API key' } + ), + }, + { + disabled: !canSaveRule, + className: 'ruleActionsPopover__deleteButton', + 'data-test-subj': 'deleteRuleButton', + onClick: () => { + setIsPopoverOpen(false); + onDelete(rule.id); + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.deleteRuleButtonLabel', + { defaultMessage: 'Delete rule' } + ), + }, + ], + }, + ]} + className="ruleActionsPopover" + data-test-subj="ruleActionsPopover" + data-testid="ruleActionsPopover" + /> + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index 7857eb172eedb..59f4a1bc70e03 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -44,6 +44,12 @@ jest.mock('../../../lib/action_connector_api', () => ({ loadAllActions: jest.fn().mockResolvedValue([]), })); +jest.mock('../../../lib/rule_api', () => ({ + updateAPIKey: jest.fn(), + deleteRules: jest.fn(), +})); +const { updateAPIKey, deleteRules } = jest.requireMock('../../../lib/rule_api'); + jest.mock('../../../lib/capabilities', () => ({ hasAllPrivilege: jest.fn(() => true), hasSaveRulesCapability: jest.fn(() => true), @@ -83,219 +89,225 @@ const ruleType: RuleType = { }; describe('rule_details', () => { - it('renders the rule name as a title', () => { - const rule = mockRule(); - expect( - shallow( - - ).find('EuiPageHeader') - ).toBeTruthy(); - }); + describe('page', () => { + it('renders the rule name as a title', () => { + const rule = mockRule(); + expect( + shallow( + + ).find('EuiPageHeader') + ).toBeTruthy(); + }); - it('renders the rule type badge', () => { - const rule = mockRule(); - expect( - shallow( - - ).find({ruleType.name}) - ).toBeTruthy(); - }); + it('renders the rule type badge', () => { + const rule = mockRule(); + expect( + shallow( + + ).find({ruleType.name}) + ).toBeTruthy(); + }); - it('renders the API key owner badge when user can manage API keys', () => { - const rule = mockRule(); - expect( - shallow( - - ).find({rule.apiKeyOwner}) - ).toBeTruthy(); - }); + it('renders the API key owner badge when user can manage API keys', () => { + const rule = mockRule(); + expect( + shallow( + + ).find({rule.apiKeyOwner}) + ).toBeTruthy(); + }); - it(`doesn't render the API key owner badge when user can't manage API keys`, () => { - const { hasManageApiKeysCapability } = jest.requireMock('../../../lib/capabilities'); - hasManageApiKeysCapability.mockReturnValueOnce(false); - const rule = mockRule(); - expect( - shallow() - .find({rule.apiKeyOwner}) - .exists() - ).toBeFalsy(); - }); + it(`doesn't render the API key owner badge when user can't manage API keys`, () => { + const { hasManageApiKeysCapability } = jest.requireMock('../../../lib/capabilities'); + hasManageApiKeysCapability.mockReturnValueOnce(false); + const rule = mockRule(); + expect( + shallow() + .find({rule.apiKeyOwner}) + .exists() + ).toBeFalsy(); + }); - it('renders the rule error banner with error message, when rule has a license error', () => { - const rule = mockRule({ - enabled: true, - executionStatus: { - status: 'error', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: RuleExecutionStatusErrorReasons.License, - message: 'test', + it('renders the rule error banner with error message, when rule has a license error', () => { + const rule = mockRule({ + enabled: true, + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, }, - }, + }); + const wrapper = shallow( + + ); + expect( + wrapper.find('[data-test-subj="ruleErrorBanner"]').first().text() + ).toMatchInlineSnapshot(`" Cannot run rule, test "`); }); - const wrapper = shallow( - - ); - expect(wrapper.find('[data-test-subj="ruleErrorBanner"]').first().text()).toMatchInlineSnapshot( - `" Cannot run rule, test "` - ); - }); - it('renders the rule warning banner with warning message, when rule status is a warning', () => { - const rule = mockRule({ - enabled: true, - executionStatus: { - status: 'warning', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - warning: { - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - message: 'warning message', + it('renders the rule warning banner with warning message, when rule status is a warning', () => { + const rule = mockRule({ + enabled: true, + executionStatus: { + status: 'warning', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'warning message', + }, }, - }, + }); + const wrapper = shallow( + + ); + expect( + wrapper.find('[data-test-subj="ruleWarningBanner"]').first().text() + ).toMatchInlineSnapshot(`" Action limit exceeded warning message"`); }); - const wrapper = shallow( - - ); - expect( - wrapper.find('[data-test-subj="ruleWarningBanner"]').first().text() - ).toMatchInlineSnapshot(`" Action limit exceeded warning message"`); - }); - it('displays a toast message when interval is less than configured minimum', async () => { - const rule = mockRule({ - schedule: { - interval: '1s', - }, - }); - const wrapper = mountWithIntl( - - ); + it('displays a toast message when interval is less than configured minimum', async () => { + const rule = mockRule({ + schedule: { + interval: '1s', + }, + }); + const wrapper = mountWithIntl( + + ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + await nextTick(); + wrapper.update(); + }); - expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); - }); + expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); + }); - describe('actions', () => { - it('renders an rule action', () => { - const rule = mockRule({ - actions: [ + describe('actions', () => { + it('renders an rule action', () => { + const rule = mockRule({ + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + + const actionTypes: ActionType[] = [ { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, - ], + ]; + + expect( + mountWithIntl( + + ).containsMatchingElement( + + {actionTypes[0].name} + + ) + ).toBeTruthy(); }); - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; + it('renders a counter for multiple rule action', () => { + const rule = mockRule({ + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.email', + }, + ], + }); + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + { + id: '.email', + name: 'Send email', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; - expect( - mountWithIntl( + const details = mountWithIntl( - ).containsMatchingElement( - - {actionTypes[0].name} - - ) - ).toBeTruthy(); + ); + + expect( + details.containsMatchingElement( + + {actionTypes[0].name} + + ) + ).toBeTruthy(); + + expect( + details.containsMatchingElement( + + {actionTypes[1].name} + + ) + ).toBeTruthy(); + }); }); - it('renders a counter for multiple rule action', () => { - const rule = mockRule({ - actions: [ - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.email', - }, - ], + describe('links', () => { + it('links to the app that created the rule', () => { + const rule = mockRule(); + expect( + shallow( + + ).find('ViewInApp') + ).toBeTruthy(); }); - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: '.email', - name: 'Send email', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - - const details = mountWithIntl( - - ); - expect( - details.containsMatchingElement( - - {actionTypes[0].name} - - ) - ).toBeTruthy(); - - expect( - details.containsMatchingElement( - - {actionTypes[1].name} - - ) - ).toBeTruthy(); - }); - }); - - describe('links', () => { - it('links to the app that created the rule', () => { - const rule = mockRule(); - expect( - shallow( + it('links to the Edit flyout', () => { + const rule = mockRule(); + const pageHeaderProps = shallow( - ).find('ViewInApp') - ).toBeTruthy(); - }); - - it('links to the Edit flyout', () => { - const rule = mockRule(); - const pageHeaderProps = shallow( - - ) - .find('EuiPageHeader') - .props() as EuiPageHeaderProps; - const rightSideItems = pageHeaderProps.rightSideItems; - expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` + ) + .find('EuiPageHeader') + .props() as EuiPageHeaderProps; + const rightSideItems = pageHeaderProps.rightSideItems; + expect(!!rightSideItems && rightSideItems[1]!).toMatchInlineSnapshot(` { `); + }); }); }); -}); -describe('disable/enable functionality', () => { - it('should show that the rule is enabled', () => { - const rule = mockRule({ - enabled: true, + describe('disable/enable functionality', () => { + it('should show that the rule is enabled', () => { + const rule = mockRule({ + enabled: true, + }); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); + + expect(actionsElem.text()).toEqual('Enabled'); }); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - expect(actionsElem.text()).toEqual('Enabled'); - }); + it('should show that the rule is disabled', async () => { + const rule = mockRule({ + enabled: false, + }); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - it('should show that the rule is disabled', async () => { - const rule = mockRule({ - enabled: false, + expect(actionsElem.text()).toEqual('Disabled'); }); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper.find('[data-test-subj="statusDropdown"]').first(); - expect(actionsElem.text()).toEqual('Disabled'); - }); + it('should disable the rule when picking disable in the dropdown', async () => { + const rule = mockRule({ + enabled: true, + }); + const disableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('should disable the rule when picking disable in the dropdown', async () => { - const rule = mockRule({ - enabled: true, - }); - const disableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(1).simulate('click'); - await nextTick(); + expect(disableRule).toHaveBeenCalledTimes(1); }); - expect(disableRule).toHaveBeenCalledTimes(1); - }); + it('if rule is already disable should do nothing when picking disable in the dropdown', async () => { + const rule = mockRule({ + enabled: false, + }); + const disableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('if rule is already disable should do nothing when picking disable in the dropdown', async () => { - const rule = mockRule({ - enabled: false, - }); - const disableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(1).simulate('click'); - await nextTick(); + expect(disableRule).toHaveBeenCalledTimes(0); }); - expect(disableRule).toHaveBeenCalledTimes(0); - }); + it('should enable the rule when picking enable in the dropdown', async () => { + const rule = mockRule({ + enabled: false, + }); + const enableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('should enable the rule when picking enable in the dropdown', async () => { - const rule = mockRule({ - enabled: false, - }); - const enableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(0).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(0).simulate('click'); - await nextTick(); + expect(enableRule).toHaveBeenCalledTimes(1); }); - expect(enableRule).toHaveBeenCalledTimes(1); - }); + it('if rule is already enable should do nothing when picking enable in the dropdown', async () => { + const rule = mockRule({ + enabled: true, + }); + const enableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); - it('if rule is already enable should do nothing when picking enable in the dropdown', async () => { - const rule = mockRule({ - enabled: true, - }); - const enableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(0).simulate('click'); + await nextTick(); + }); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(0).simulate('click'); - await nextTick(); + expect(enableRule).toHaveBeenCalledTimes(0); }); - expect(enableRule).toHaveBeenCalledTimes(0); - }); + it('should show the loading spinner when the rule enabled switch was clicked and the server responded with some delay', async () => { + const rule = mockRule({ + enabled: true, + }); - it('should show the loading spinner when the rule enabled switch was clicked and the server responded with some delay', async () => { - const rule = mockRule({ - enabled: true, - }); + const disableRule = jest.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 6000)); + }); + const enableRule = jest.fn(); + const wrapper = mountWithIntl( + + ); - const disableRule = jest.fn(async () => { - await new Promise((resolve) => setTimeout(resolve, 6000)); - }); - const enableRule = jest.fn(); - const wrapper = mountWithIntl( - - ); - - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - actionsElem.simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + actionsElem.simulate('click'); - await act(async () => { - const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); - const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); - actionsMenuItemElem.at(1).simulate('click'); - }); + await act(async () => { + await nextTick(); + wrapper.update(); + }); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await act(async () => { + const actionsMenuElem = wrapper.find('[data-test-subj="ruleStatusMenu"]'); + const actionsMenuItemElem = actionsMenuElem.first().find('.euiContextMenuItem'); + actionsMenuItemElem.at(1).simulate('click'); + }); - await act(async () => { - expect(disableRule).toHaveBeenCalled(); - expect( - wrapper.find('[data-test-subj="statusDropdown"] .euiBadge__childButton .euiLoadingSpinner') - .length - ).toBeGreaterThan(0); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + expect(disableRule).toHaveBeenCalled(); + expect( + wrapper.find( + '[data-test-subj="statusDropdown"] .euiBadge__childButton .euiLoadingSpinner' + ).length + ).toBeGreaterThan(0); + }); }); }); -}); -describe('snooze functionality', () => { - it('should render "Snooze Indefinitely" when rule is enabled and mute all', () => { - const rule = mockRule({ - enabled: true, - muteAll: true, + describe('snooze functionality', () => { + it('should render "Snooze Indefinitely" when rule is enabled and mute all', () => { + const rule = mockRule({ + enabled: true, + muteAll: true, + }); + const wrapper = mountWithIntl( + + ); + const actionsElem = wrapper + .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') + .first(); + expect(actionsElem.text()).toEqual('Snoozed'); + expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toEqual( + 'Indefinitely' + ); }); - const wrapper = mountWithIntl( - - ); - const actionsElem = wrapper - .find('[data-test-subj="statusDropdown"] .euiBadge__childButton') - .first(); - expect(actionsElem.text()).toEqual('Snoozed'); - expect(wrapper.find('[data-test-subj="remainingSnoozeTime"]').first().text()).toEqual( - 'Indefinitely' - ); }); -}); -describe('edit button', () => { - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - ruleTypeRegistry.has.mockReturnValue(true); - const ruleTypeR: RuleTypeModel = { - id: 'my-rule-type', - iconClass: 'test', - description: 'Rule when testing', - documentationUrl: 'https://localhost.local/docs', - validate: () => { - return { errors: {} }; - }, - ruleParamsExpression: jest.fn(), - requiresAppContext: false, - }; - ruleTypeRegistry.get.mockReturnValue(ruleTypeR); - useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; - - it('should render an edit button when rule and actions are editable', () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', - }, - ], - }); - const pageHeaderProps = shallow( - - ) - .find('EuiPageHeader') - .props() as EuiPageHeaderProps; - const rightSideItems = pageHeaderProps.rightSideItems; - expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` + describe('edit button', () => { + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + ruleTypeRegistry.has.mockReturnValue(true); + const ruleTypeR: RuleTypeModel = { + id: 'my-rule-type', + iconClass: 'test', + description: 'Rule when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + ruleParamsExpression: jest.fn(), + requiresAppContext: false, + }; + ruleTypeRegistry.get.mockReturnValue(ruleTypeR); + useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; + + it('should render an edit button when rule and actions are editable', () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const pageHeaderProps = shallow( + + ) + .find('EuiPageHeader') + .props() as EuiPageHeaderProps; + const rightSideItems = pageHeaderProps.rightSideItems; + expect(!!rightSideItems && rightSideItems[1]!).toMatchInlineSnapshot(` { `); - }); + }); - it('should not render an edit button when rule editable but actions arent', () => { - const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValueOnce(false); - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: uuid.v4(), - params: {}, - actionTypeId: '.server-log', - }, - ], + it('should not render an edit button when rule editable but actions arent', () => { + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValueOnce(false); + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + expect( + shallow( + + ) + .find(EuiButtonEmpty) + .find('[name="edit"]') + .first() + .exists() + ).toBeFalsy(); }); - expect( - shallow( + + it('should render an edit button when rule editable but actions arent when there are no actions on the rule', async () => { + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValueOnce(false); + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [], + }); + const pageHeaderProps = shallow( ) - .find(EuiButtonEmpty) - .find('[name="edit"]') - .first() - .exists() - ).toBeFalsy(); - }); - - it('should render an edit button when rule editable but actions arent when there are no actions on the rule', async () => { - const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValueOnce(false); - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [], - }); - const pageHeaderProps = shallow( - - ) - .find('EuiPageHeader') - .props() as EuiPageHeaderProps; - const rightSideItems = pageHeaderProps.rightSideItems; - expect(!!rightSideItems && rightSideItems[2]!).toMatchInlineSnapshot(` + .find('EuiPageHeader') + .props() as EuiPageHeaderProps; + const rightSideItems = pageHeaderProps.rightSideItems; + expect(!!rightSideItems && rightSideItems[1]!).toMatchInlineSnapshot(` { `); + }); }); -}); -describe('broken connector indicator', () => { - const actionTypes: ActionType[] = [ - { - id: '.server-log', - name: 'Server log', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]; - ruleTypeRegistry.has.mockReturnValue(true); - const ruleTypeR: RuleTypeModel = { - id: 'my-rule-type', - iconClass: 'test', - description: 'Rule when testing', - documentationUrl: 'https://localhost.local/docs', - validate: () => { - return { errors: {} }; - }, - ruleParamsExpression: jest.fn(), - requiresAppContext: false, - }; - ruleTypeRegistry.get.mockReturnValue(ruleTypeR); - useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; - const { loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); - loadAllActions.mockResolvedValue([ - { - secrets: {}, - isMissingSecrets: false, - id: 'connector-id-1', - actionTypeId: '.server-log', - name: 'Test connector', - config: {}, - isPreconfigured: false, - isDeprecated: false, - }, - { - secrets: {}, - isMissingSecrets: false, - id: 'connector-id-2', - actionTypeId: '.server-log', - name: 'Test connector 2', - config: {}, - isPreconfigured: false, - isDeprecated: false, - }, - ]); - - it('should not render broken connector indicator or warning if all rule actions connectors exist', async () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: 'connector-id-1', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-2', - params: {}, - actionTypeId: '.server-log', - }, - ], + describe('broken connector indicator', () => { + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + ruleTypeRegistry.has.mockReturnValue(true); + const ruleTypeR: RuleTypeModel = { + id: 'my-rule-type', + iconClass: 'test', + description: 'Rule when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + ruleParamsExpression: jest.fn(), + requiresAppContext: false, + }; + ruleTypeRegistry.get.mockReturnValue(ruleTypeR); + useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; + const { loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); + loadAllActions.mockResolvedValue([ + { + secrets: {}, + isMissingSecrets: false, + id: 'connector-id-1', + actionTypeId: '.server-log', + name: 'Test connector', + config: {}, + isPreconfigured: false, + isDeprecated: false, + }, + { + secrets: {}, + isMissingSecrets: false, + id: 'connector-id-2', + actionTypeId: '.server-log', + name: 'Test connector 2', + config: {}, + isPreconfigured: false, + isDeprecated: false, + }, + ]); + + it('should not render broken connector indicator or warning if all rule actions connectors exist', async () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeFalsy(); + expect(brokenConnectorWarningBanner.exists()).toBeFalsy(); }); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await nextTick(); - wrapper.update(); + + it('should render broken connector indicator and warning if any rule actions connector does not exist', async () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-doesnt-exist', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + const brokenConnectorWarningBannerAction = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeTruthy(); + expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); + expect(brokenConnectorWarningBannerAction.exists()).toBeTruthy(); }); - const brokenConnectorIndicator = wrapper - .find('[data-test-subj="actionWithBrokenConnector"]') - .first(); - const brokenConnectorWarningBanner = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') - .first(); - expect(brokenConnectorIndicator.exists()).toBeFalsy(); - expect(brokenConnectorWarningBanner.exists()).toBeFalsy(); - }); - it('should render broken connector indicator and warning if any rule actions connector does not exist', async () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: 'connector-id-1', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-2', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-doesnt-exist', - params: {}, - actionTypeId: '.server-log', - }, - ], + it('should render broken connector indicator and warning with no edit button if any rule actions connector does not exist and user has no edit access', async () => { + const rule = mockRule({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: 'connector-id-1', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-2', + params: {}, + actionTypeId: '.server-log', + }, + { + group: 'default', + id: 'connector-id-doesnt-exist', + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValue(false); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const brokenConnectorIndicator = wrapper + .find('[data-test-subj="actionWithBrokenConnector"]') + .first(); + const brokenConnectorWarningBanner = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') + .first(); + const brokenConnectorWarningBannerAction = wrapper + .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') + .first(); + expect(brokenConnectorIndicator.exists()).toBeTruthy(); + expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); + expect(brokenConnectorWarningBannerAction.exists()).toBeFalsy(); }); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await nextTick(); - wrapper.update(); + }); + + describe('refresh button', () => { + it('should call requestRefresh when clicked', async () => { + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + const refreshButton = wrapper.find('[data-test-subj="refreshRulesButton"]').first(); + expect(refreshButton.exists()).toBeTruthy(); + + refreshButton.simulate('click'); + expect(requestRefresh).toHaveBeenCalledTimes(1); }); - const brokenConnectorIndicator = wrapper - .find('[data-test-subj="actionWithBrokenConnector"]') - .first(); - const brokenConnectorWarningBanner = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') - .first(); - const brokenConnectorWarningBannerAction = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') - .first(); - expect(brokenConnectorIndicator.exists()).toBeTruthy(); - expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); - expect(brokenConnectorWarningBannerAction.exists()).toBeTruthy(); }); - it('should render broken connector indicator and warning with no edit button if any rule actions connector does not exist and user has no edit access', async () => { - const rule = mockRule({ - enabled: true, - muteAll: false, - actions: [ - { - group: 'default', - id: 'connector-id-1', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-2', - params: {}, - actionTypeId: '.server-log', - }, - { - group: 'default', - id: 'connector-id-doesnt-exist', - params: {}, - actionTypeId: '.server-log', - }, - ], + describe('update API key button', () => { + it('should call update api key when clicked', async () => { + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const updateButton = wrapper.find('[data-test-subj="updateAPIKeyButton"]').first(); + expect(updateButton.exists()).toBeTruthy(); + + updateButton.simulate('click'); + + const confirm = wrapper.find('[data-test-subj="updateApiKeyIdsConfirmation"]').first(); + expect(confirm.exists()).toBeTruthy(); + + const confirmButton = wrapper.find('[data-test-subj="confirmModalConfirmButton"]').first(); + expect(confirmButton.exists()).toBeTruthy(); + + confirmButton.simulate('click'); + + expect(updateAPIKey).toHaveBeenCalledTimes(1); + expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: rule.id })); }); - const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); - hasExecuteActionsCapability.mockReturnValue(false); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await nextTick(); - wrapper.update(); + }); + + describe('delete rule button', () => { + it('should delete the rule when clicked', async () => { + deleteRules.mockResolvedValueOnce({ successes: ['1'], errors: [] }); + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const updateButton = wrapper.find('[data-test-subj="deleteRuleButton"]').first(); + expect(updateButton.exists()).toBeTruthy(); + + updateButton.simulate('click'); + + const confirm = wrapper.find('[data-test-subj="deleteIdsConfirmation"]').first(); + expect(confirm.exists()).toBeTruthy(); + + const confirmButton = wrapper.find('[data-test-subj="confirmModalConfirmButton"]').first(); + expect(confirmButton.exists()).toBeTruthy(); + + confirmButton.simulate('click'); + + expect(deleteRules).toHaveBeenCalledTimes(1); + expect(deleteRules).toHaveBeenCalledWith(expect.objectContaining({ ids: [rule.id] })); }); - const brokenConnectorIndicator = wrapper - .find('[data-test-subj="actionWithBrokenConnector"]') - .first(); - const brokenConnectorWarningBanner = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBanner"]') - .first(); - const brokenConnectorWarningBannerAction = wrapper - .find('[data-test-subj="actionWithBrokenConnectorWarningBannerEdit"]') - .first(); - expect(brokenConnectorIndicator.exists()).toBeTruthy(); - expect(brokenConnectorWarningBanner.exists()).toBeTruthy(); - expect(brokenConnectorWarningBannerAction.exists()).toBeFalsy(); }); -}); -describe('refresh button', () => { - it('should call requestRefresh when clicked', async () => { - const rule = mockRule(); - const requestRefresh = jest.fn(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); + describe('enable/disable rule button', () => { + it('should disable the rule when clicked', async () => { + const rule = mockRule(); + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const disableButton = wrapper.find('[data-test-subj="disableButton"]').first(); + expect(disableButton.exists()).toBeTruthy(); + + disableButton.simulate('click'); + + expect(mockRuleApis.disableRule).toHaveBeenCalledTimes(1); + expect(mockRuleApis.disableRule).toHaveBeenCalledWith(rule); }); - const refreshButton = wrapper.find('[data-test-subj="refreshRulesButton"]').first(); - expect(refreshButton.exists()).toBeTruthy(); - refreshButton.simulate('click'); - expect(requestRefresh).toHaveBeenCalledTimes(1); + it('should enable the rule when clicked', async () => { + const rule = { ...mockRule(), enabled: false }; + const requestRefresh = jest.fn(); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + const actionsButton = wrapper.find('[data-test-subj="ruleActionsButton"]').first(); + actionsButton.simulate('click'); + + const enableButton = wrapper.find('[data-test-subj="disableButton"]').first(); + expect(enableButton.exists()).toBeTruthy(); + + enableButton.simulate('click'); + + expect(mockRuleApis.enableRule).toHaveBeenCalledTimes(1); + expect(mockRuleApis.enableRule).toHaveBeenCalledWith(rule); + }); }); -}); -function mockRule(overloads: Partial = {}): Rule { - return { - id: uuid.v4(), - enabled: true, - name: `rule-${uuid.v4()}`, - tags: [], - ruleTypeId: '.noop', - consumer: ALERTS_FEATURE_ID, - schedule: { interval: '1m' }, - actions: [], - params: {}, - createdBy: null, - updatedBy: null, - createdAt: new Date(), - updatedAt: new Date(), - apiKeyOwner: 'bob', - throttle: null, - notifyWhen: null, - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - ...overloads, - }; -} + function mockRule(overloads: Partial = {}): Rule { + return { + id: uuid.v4(), + enabled: true, + name: `rule-${uuid.v4()}`, + tags: [], + ruleTypeId: '.noop', + consumer: ALERTS_FEATURE_ID, + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + ...overloads, + }; + } +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index 0389e6b0d9b30..ee7266fa06331 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,6 +27,10 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { RuleExecutionStatusErrorReasons, parseDuration } from '@kbn/alerting-plugin/common'; +import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; +import { updateAPIKey, deleteRules } from '../../../lib/rule_api'; +import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; +import { RuleActionsPopover } from './rule_actions_popover'; import { hasAllPrivilege, hasExecuteActionsCapability, @@ -49,7 +53,7 @@ import { import { RuleRouteWithApi } from './rule_route'; import { ViewInApp } from './view_in_app'; import { RuleEdit } from '../../rule_form'; -import { routeToRuleDetails } from '../../../constants'; +import { routeToRuleDetails, routeToRules } from '../../../constants'; import { rulesErrorReasonTranslationsMapping, rulesWarningReasonTranslationsMapping, @@ -94,6 +98,9 @@ export const RuleDetails: React.FunctionComponent = ({ dispatch({ command: { type: 'setRule' }, payload: { key: 'rule', value } }); }; + const [rulesToDelete, setRulesToDelete] = useState([]); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); + const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState(false); @@ -207,6 +214,10 @@ export const RuleDetails: React.FunctionComponent = ({ history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }; + const goToRulesList = () => { + history.push(routeToRules); + }; + const getRuleStatusErrorReasonText = () => { if (rule.executionStatus.error && rule.executionStatus.error.reason) { return rulesErrorReasonTranslationsMapping[rule.executionStatus.error.reason]; @@ -223,40 +234,71 @@ export const RuleDetails: React.FunctionComponent = ({ } }; - const rightPageHeaderButtons = hasEditButton - ? [ - <> - setEditFlyoutVisibility(true)} - name="edit" - disabled={!ruleType.enabledInLicense} - > - - - {editFlyoutVisible && ( - { - setInitialRule(rule); - setEditFlyoutVisibility(false); - }} - actionTypeRegistry={actionTypeRegistry} - ruleTypeRegistry={ruleTypeRegistry} - ruleType={ruleType} - onSave={setRule} - /> - )} - , - ] - : []; + const editButton = hasEditButton ? ( + <> + setEditFlyoutVisibility(true)} + name="edit" + disabled={!ruleType.enabledInLicense} + > + + + {editFlyoutVisible && ( + { + setInitialRule(rule); + setEditFlyoutVisibility(false); + }} + actionTypeRegistry={actionTypeRegistry} + ruleTypeRegistry={ruleTypeRegistry} + ruleType={ruleType} + onSave={setRule} + /> + )} + + ) : null; return ( <> + { + setRulesToDelete([]); + goToRulesList(); + }} + onErrors={async () => { + // Refresh the rule from the server, it may have been deleted + await requestRefresh(); + setRulesToDelete([]); + }} + onCancel={() => { + setRulesToDelete([]); + }} + apiDeleteCall={deleteRules} + idsToDelete={rulesToDelete} + singleTitle={i18n.translate('xpack.triggersActionsUI.sections.rulesList.singleTitle', { + defaultMessage: 'rule', + })} + multipleTitle="" + setIsLoadingState={() => {}} + /> + { + setRulesToUpdateAPIKey([]); + }} + idsToUpdate={rulesToUpdateAPIKey} + apiUpdateApiKeyCall={updateAPIKey} + setIsLoadingState={() => {}} + onUpdated={async () => { + setRulesToUpdateAPIKey([]); + requestRefresh(); + }} + /> = ({ } rightSideItems={[ - , + { + setRulesToDelete([ruleId]); + }} + onApiKeyUpdate={(ruleId) => { + setRulesToUpdateAPIKey([ruleId]); + }} + onEnableDisable={async (enable) => { + if (enable) { + await enableRule(rule); + } else { + await disableRule(rule); + } + requestRefresh(); + }} + />, + editButton, = ({ defaultMessage="Refresh" /> , - ...rightPageHeaderButtons, + , ]} /> @@ -422,7 +482,6 @@ export const RuleDetails: React.FunctionComponent = ({ ) : null} - {rule.enabled && rule.executionStatus.status === 'warning' ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss index ffe000073aa75..fe009998ff1c4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.scss @@ -1,9 +1,3 @@ -.actCollapsedItemActions { - .euiContextMenuItem:hover { - text-decoration: none; - } -} - -button[data-test-subj='deleteRule'] { - color: $euiColorDanger; +.collapsedItemActions__deleteButton { + color: $euiColorDangerText; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx index 8ce6736aee8ad..7759c940b8865 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx @@ -21,6 +21,7 @@ const disableRule = jest.fn(); const enableRule = jest.fn(); const unmuteRule = jest.fn(); const muteRule = jest.fn(); +const onUpdateAPIKey = jest.fn(); export const tick = (ms = 0) => new Promise((resolve) => { @@ -91,6 +92,7 @@ describe('CollapsedItemActions', () => { enableRule, unmuteRule, muteRule, + onUpdateAPIKey, }; }; @@ -118,6 +120,7 @@ describe('CollapsedItemActions', () => { expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="editRule"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="deleteRule"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="updateApiKey"]').exists()).toBeFalsy(); wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); await act(async () => { @@ -130,6 +133,7 @@ describe('CollapsedItemActions', () => { expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="editRule"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="deleteRule"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="updateApiKey"]').exists()).toBeTruthy(); expect( wrapper.find('[data-test-subj="selectActionButton"]').first().props().disabled @@ -143,6 +147,7 @@ describe('CollapsedItemActions', () => { expect(wrapper.find(`[data-test-subj="editRule"] button`).text()).toEqual('Edit rule'); expect(wrapper.find(`[data-test-subj="deleteRule"] button`).prop('disabled')).toBeFalsy(); expect(wrapper.find(`[data-test-subj="deleteRule"] button`).text()).toEqual('Delete rule'); + expect(wrapper.find(`[data-test-subj="updateApiKey"] button`).text()).toEqual('Update API key'); }); test('handles case when rule is unmuted and enabled and mute is clicked', async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx index 4fcecc3410f17..7e65961ac80f0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.tsx @@ -23,6 +23,7 @@ export type ComponentOpts = { onRuleChanged: () => void; setRulesToDelete: React.Dispatch>; onEditRule: (item: RuleTableItem) => void; + onUpdateAPIKey: (id: string[]) => void; } & Pick; export const CollapsedItemActions: React.FunctionComponent = ({ @@ -34,6 +35,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ muteRule, setRulesToDelete, onEditRule, + onUpdateAPIKey, }: ComponentOpts) => { const { ruleTypeRegistry } = useKibana().services; @@ -53,6 +55,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ setIsPopoverOpen(!isPopoverOpen)} aria-label={i18n.translate( @@ -133,6 +136,19 @@ export const CollapsedItemActions: React.FunctionComponent = ({ }, { disabled: !item.isEditable, + 'data-test-subj': 'updateApiKey', + onClick: () => { + setIsPopoverOpen(!isPopoverOpen); + onUpdateAPIKey([item.id]); + }, + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.collapsedItemActions.updateApiKey', + { defaultMessage: 'Update API key' } + ), + }, + { + disabled: !item.isEditable, + className: 'collapsedItemActions__deleteButton', 'data-test-subj': 'deleteRule', onClick: () => { setIsPopoverOpen(!isPopoverOpen); @@ -161,6 +177,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ panels={panels} className="actCollapsedItemActions" data-test-subj="collapsedActionPanel" + data-testid="collapsedActionPanel" /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 12e1b0f1e4a6e..7827033138fbb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -6,9 +6,9 @@ */ import * as React from 'react'; +import { fireEvent, act, render, screen } from '@testing-library/react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { ReactWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; import { RulesList, percentileFields } from './rules_list'; @@ -23,8 +23,10 @@ import { getFormattedDuration, getFormattedMilliseconds } from '../../../lib/mon import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; import { useKibana } from '../../../../common/lib/kibana'; -jest.mock('../../../../common/lib/kibana'); +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { IToasts } from '@kbn/core/public'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/action_connector_api', () => ({ loadActionTypes: jest.fn(), loadAllActions: jest.fn(), @@ -33,6 +35,7 @@ jest.mock('../../../lib/rule_api', () => ({ loadRules: jest.fn(), loadRuleTypes: jest.fn(), loadRuleAggregations: jest.fn(), + updateAPIKey: jest.fn(), loadRuleTags: jest.fn(), alertingFrameworkHealth: jest.fn(() => ({ isSufficientlySecure: true, @@ -67,12 +70,12 @@ jest.mock('../../../../common/get_experimental_features', () => ({ const ruleTags = ['a', 'b', 'c', 'd']; -const { loadRules, loadRuleTypes, loadRuleAggregations, loadRuleTags } = +const { loadRules, loadRuleTypes, loadRuleAggregations, updateAPIKey, loadRuleTags } = jest.requireMock('../../../lib/rule_api'); const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); + const actionTypeRegistry = actionTypeRegistryMock.create(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); - const ruleType = { id: 'test_rule_type', description: 'test', @@ -101,11 +104,299 @@ const ruleTypeFromApi = { }; ruleTypeRegistry.list.mockReturnValue([ruleType]); actionTypeRegistry.list.mockReturnValue([]); + const useKibanaMock = useKibana as jest.Mocked; +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + beforeEach(() => { (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => false); }); +describe('Update Api Key', () => { + const addSuccess = jest.fn(); + const addError = jest.fn(); + + beforeAll(() => { + loadRules.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: mockedRulesData, + }); + loadActionTypes.mockResolvedValue([]); + loadRuleTypes.mockResolvedValue([ruleTypeFromApi]); + loadAllActions.mockResolvedValue([]); + useKibanaMock().services.notifications.toasts = { + addSuccess, + addError, + } as unknown as IToasts; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Updates the Api Key successfully', async () => { + updateAPIKey.mockResolvedValueOnce(204); + render( + + + + ); + expect(await screen.findByText('test rule ok')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('selectActionButton')[1]); + expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Update API key')); + expect(screen.getByText('You will not be able to recover the old API key')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Cancel')); + expect( + screen.queryByText('You will not be able to recover the old API key') + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('selectActionButton')[1]); + expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Update API key')); + + await act(async () => { + fireEvent.click(screen.getByText('Update')); + }); + expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); + expect(loadRules).toHaveBeenCalledTimes(2); + expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument(); + expect(addSuccess).toHaveBeenCalledWith('API key has been updated'); + }); + + it('Update API key fails', async () => { + updateAPIKey.mockRejectedValueOnce(500); + render( + + + + ); + + expect(await screen.findByText('test rule ok')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByTestId('selectActionButton')[1]); + expect(screen.getByTestId('collapsedActionPanel')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Update API key')); + expect(screen.getByText('You will not be able to recover the old API key')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText('Update')); + }); + expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); + expect(loadRules).toHaveBeenCalledTimes(2); + expect( + screen.queryByText('You will not be able to recover the old API key') + ).not.toBeInTheDocument(); + expect(addError).toHaveBeenCalledWith(500, { title: 'Failed to update the API key' }); + }); +}); describe('rules_list component empty', () => { let wrapper: ReactWrapper; @@ -174,208 +465,6 @@ describe('rules_list component empty', () => { describe('rules_list component with items', () => { let wrapper: ReactWrapper; - const mockedRulesData = [ - { - id: '1', - name: 'test rule', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '1s' }, - actions: [], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'active', - lastDuration: 500, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - monitoring: { - execution: { - history: [ - { - success: true, - duration: 1000000, - }, - { - success: true, - duration: 200000, - }, - { - success: false, - duration: 300000, - }, - ], - calculated_metrics: { - success_ratio: 0.66, - p50: 200000, - p95: 300000, - p99: 300000, - }, - }, - }, - }, - { - id: '2', - name: 'test rule ok', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'ok', - lastDuration: 61000, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - monitoring: { - execution: { - history: [ - { - success: true, - duration: 100000, - }, - { - success: true, - duration: 500000, - }, - ], - calculated_metrics: { - success_ratio: 1, - p50: 0, - p95: 100000, - p99: 500000, - }, - }, - }, - }, - { - id: '3', - name: 'test rule pending', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'pending', - lastDuration: 30234, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - monitoring: { - execution: { - history: [{ success: false, duration: 100 }], - calculated_metrics: { - success_ratio: 0, - }, - }, - }, - }, - { - id: '4', - name: 'test rule error', - tags: ['tag1'], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'error', - lastDuration: 122000, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: RuleExecutionStatusErrorReasons.Unknown, - message: 'test', - }, - }, - }, - { - id: '5', - name: 'test rule license error', - tags: [], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'error', - lastDuration: 500, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: RuleExecutionStatusErrorReasons.License, - message: 'test', - }, - }, - }, - { - id: '6', - name: 'test rule warning', - tags: [], - enabled: true, - ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], - params: { name: 'test rule type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'warning', - lastDuration: 500, - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - warning: { - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - message: 'test', - }, - }, - }, - ]; - async function setup(editable: boolean = true) { loadRules.mockResolvedValue({ page: 1, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index a5b9661835131..c1612290dfdd2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -80,6 +80,7 @@ import { snoozeRule, unsnoozeRule, deleteRules, + updateAPIKey, } from '../../../lib/rule_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; @@ -103,6 +104,7 @@ import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; import { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; const ENTER_KEY = 13; @@ -219,6 +221,7 @@ export const RulesList: React.FunctionComponent = () => { totalItemCount: 0, }); const [rulesToDelete, setRulesToDelete] = useState([]); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); setCurrentRuleToEdit(ruleItem); @@ -903,6 +906,7 @@ export const RulesList: React.FunctionComponent = () => { onRuleChanged={() => loadRulesData()} setRulesToDelete={setRulesToDelete} onEditRule={() => onRuleEdit(item)} + onUpdateAPIKey={setRulesToUpdateAPIKey} /> @@ -1330,7 +1334,7 @@ export const RulesList: React.FunctionComponent = () => { await loadRulesData(); }} onErrors={async () => { - // Refresh the rules from the server, some rules may have beend deleted + // Refresh the rules from the server, some rules may have been deleted await loadRulesData(); setRulesToDelete([]); }} @@ -1349,6 +1353,20 @@ export const RulesList: React.FunctionComponent = () => { setRulesState({ ...rulesState, isLoading }); }} /> + { + setRulesToUpdateAPIKey([]); + }} + idsToUpdate={rulesToUpdateAPIKey} + apiUpdateApiKeyCall={updateAPIKey} + setIsLoadingState={(isLoading: boolean) => { + setRulesState({ ...rulesState, isLoading }); + }} + onUpdated={async () => { + setRulesToUpdateAPIKey([]); + await loadRulesData(); + }} + /> {getRulesList()} {ruleFlyoutVisible && ( diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts index 837ca4adf217b..4934e31b27f79 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts @@ -13,6 +13,7 @@ import { KibanaResponseFactory, IKibanaResponse, Logger, + SavedObject, } from '@kbn/core/server'; import { schema } from '@kbn/config-schema'; import { InvalidatePendingApiKey } from '@kbn/alerting-plugin/server/types'; @@ -364,4 +365,46 @@ export function defineRoutes( } } ); + + router.get( + { + path: '/api/alerts_fixture/rule/{id}/_get_api_key', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + const { id } = req.params; + const [, { encryptedSavedObjects, spaces }] = await core.getStartServices(); + + const spaceId = spaces ? spaces.spacesService.getSpaceId(req) : 'default'; + + let namespace: string | undefined; + if (spaces && spaceId) { + namespace = spaces.spacesService.spaceIdToNamespace(spaceId); + } + + try { + const { + attributes: { apiKey, apiKeyOwner }, + }: SavedObject = await encryptedSavedObjects + .getClient({ + includedHiddenTypes: ['alert'], + }) + .getDecryptedAsInternalUser('alert', id, { + namespace, + }); + + return res.ok({ body: { apiKey, apiKeyOwner } }); + } catch (err) { + return res.badRequest({ body: err }); + } + } + ); } diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 436a98d4cf3f8..2525e7fa50b7e 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -76,6 +76,16 @@ export class AlertUtils { return request; } + public getAPIKeyRequest(ruleId: string) { + const request = this.supertestWithoutAuth.get( + `${getUrlPrefix(this.space.id)}/api/alerts_fixture/rule/${ruleId}/_get_api_key` + ); + if (this.user) { + return request.auth(this.user.username, this.user.password); + } + return request; + } + public getDisableRequest(alertId: string) { const request = this.supertestWithoutAuth .post(`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/_disable`) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts index 864de743ea343..842a00366945a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/disable.ts @@ -334,13 +334,6 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte } catch (e) { expect(e.meta.statusCode).to.eql(404); } - // Ensure AAD isn't broken - await checkAAD({ - supertest, - spaceId: space.id, - type: 'alert', - id: createdAlert.id, - }); break; default: throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts index a5c81a849d8f8..53b7e8e3fb2c0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts @@ -31,6 +31,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get_alert_summary')); loadTestFile(require.resolve('./rule_types')); loadTestFile(require.resolve('./bulk_edit')); + loadTestFile(require.resolve('./retain_api_key')); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts new file mode 100644 index 0000000000000..51b50ae3dc6b3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/retain_api_key.ts @@ -0,0 +1,111 @@ +/* + * 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 { UserAtSpaceScenarios } from '../../../scenarios'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { AlertUtils, getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function retainAPIKeyTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('retain api key', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + const alertUtils = new AlertUtils({ user, space, supertestWithoutAuth }); + + describe(scenario.id, () => { + it('should retain the api key when a rule is disabled and then enabled', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + const { + body: { apiKey, apiKeyOwner }, + } = await alertUtils.getAPIKeyRequest(createdRule.id); + + expect(apiKey).not.to.be(null); + expect(apiKey).not.to.be(undefined); + expect(apiKeyOwner).not.to.be(null); + expect(apiKeyOwner).not.to.be(undefined); + + await alertUtils.getDisableRequest(createdRule.id); + + const { + body: { apiKey: apiKeyAfterDisable, apiKeyOwner: apiKeyOwnerAfterDisable }, + } = await alertUtils.getAPIKeyRequest(createdRule.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(apiKey).to.be(apiKeyAfterDisable); + expect(apiKeyOwner).to.be(apiKeyOwnerAfterDisable); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + + await alertUtils.getEnableRequest(createdRule.id); + + const { + body: { apiKey: apiKeyAfterEnable, apiKeyOwner: apiKeyOwnerAfterEnable }, + } = await alertUtils.getAPIKeyRequest(createdRule.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(apiKey).to.be(apiKeyAfterEnable); + expect(apiKeyOwner).to.be(apiKeyOwnerAfterEnable); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +}