[ResponseOps][Rules] Allow users to delete snooze schedule from a rule (#213247)

## Summary

Resolves https://github.com/elastic/kibana/issues/198783

This PR allows to delete existing snooze schedule from a rule using
schedule id.


### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### How to test
- Create a rule in kibana
- Snooze that rule via new public API
- delete that snooze schedule via public api

Method: `DELETE`
Path:
`https://localhost:5601/api/alerting/rule/<ruleId>/snooze_schedule/<scheduleId>`

### Flaky test runner:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/8049

### Release note
Allow users to delete a snooze schedule from a rule using schedule id

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: lcawl <lcawley@elastic.co>
This commit is contained in:
Janki Salvi 2025-03-17 14:50:47 +00:00 committed by GitHub
parent fabb1c9ffd
commit 6088eb221e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1179 additions and 35 deletions

View file

@ -5261,6 +5261,59 @@
]
}
},
"/api/alerting/rule/{ruleId}/snooze_schedule/{scheduleId}": {
"delete": {
"operationId": "delete-alerting-rule-ruleid-snooze-schedule-scheduleid",
"parameters": [
{
"description": "A required header to protect against CSRF attacks",
"in": "header",
"name": "kbn-xsrf",
"required": true,
"schema": {
"example": "true",
"type": "string"
}
},
{
"description": "The identifier for the rule.",
"in": "path",
"name": "ruleId",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "The identifier for the snooze schedule.",
"in": "path",
"name": "scheduleId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Indicates a successful call."
},
"400": {
"description": "Indicates an invalid schema."
},
"403": {
"description": "Indicates that this call is forbidden."
},
"404": {
"description": "Indicates a rule with the given id does not exist."
}
},
"summary": "Delete a snooze schedule for a rule",
"tags": [
"alerting"
]
}
},
"/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute": {
"post": {
"operationId": "post-alerting-rule-rule-id-alert-alert-id-mute",

View file

@ -5261,6 +5261,59 @@
]
}
},
"/api/alerting/rule/{ruleId}/snooze_schedule/{scheduleId}": {
"delete": {
"operationId": "delete-alerting-rule-ruleid-snooze-schedule-scheduleid",
"parameters": [
{
"description": "A required header to protect against CSRF attacks",
"in": "header",
"name": "kbn-xsrf",
"required": true,
"schema": {
"example": "true",
"type": "string"
}
},
{
"description": "The identifier for the rule.",
"in": "path",
"name": "ruleId",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "The identifier for the snooze schedule.",
"in": "path",
"name": "scheduleId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Indicates a successful call."
},
"400": {
"description": "Indicates an invalid schema."
},
"403": {
"description": "Indicates that this call is forbidden."
},
"404": {
"description": "Indicates a rule with the given id does not exist."
}
},
"summary": "Delete a snooze schedule for a rule",
"tags": [
"alerting"
]
}
},
"/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute": {
"post": {
"operationId": "post-alerting-rule-rule-id-alert-alert-id-mute",

View file

@ -4173,6 +4173,42 @@ paths:
tags:
- alerting
x-beta: true
/api/alerting/rule/{ruleId}/snooze_schedule/{scheduleId}:
delete:
operationId: delete-alerting-rule-ruleid-snooze-schedule-scheduleid
parameters:
- description: A required header to protect against CSRF attacks
in: header
name: kbn-xsrf
required: true
schema:
example: 'true'
type: string
- description: The identifier for the rule.
in: path
name: ruleId
required: true
schema:
type: string
- description: The identifier for the snooze schedule.
in: path
name: scheduleId
required: true
schema:
type: string
responses:
'204':
description: Indicates a successful call.
'400':
description: Indicates an invalid schema.
'403':
description: Indicates that this call is forbidden.
'404':
description: Indicates a rule with the given id does not exist.
summary: Delete a snooze schedule for a rule
tags:
- alerting
x-beta: true
/api/alerting/rules/_find:
get:
operationId: get-alerting-rules-find

View file

@ -4524,6 +4524,41 @@ paths:
summary: Unmute an alert
tags:
- alerting
/api/alerting/rule/{ruleId}/snooze_schedule/{scheduleId}:
delete:
operationId: delete-alerting-rule-ruleid-snooze-schedule-scheduleid
parameters:
- description: A required header to protect against CSRF attacks
in: header
name: kbn-xsrf
required: true
schema:
example: 'true'
type: string
- description: The identifier for the rule.
in: path
name: ruleId
required: true
schema:
type: string
- description: The identifier for the snooze schedule.
in: path
name: scheduleId
required: true
schema:
type: string
responses:
'204':
description: Indicates a successful call.
'400':
description: Indicates an invalid schema.
'403':
description: Indicates that this call is forbidden.
'404':
description: Indicates a rule with the given id does not exist.
summary: Delete a snooze schedule for a rule
tags:
- alerting
/api/alerting/rules/_find:
get:
operationId: get-alerting-rules-find

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
export const unsnoozeParamsSchema = schema.object({
ruleId: schema.string({ meta: { description: 'The identifier for the rule.' } }),
scheduleId: schema.string({
meta: { description: 'The identifier for the snooze schedule.' },
}),
});

View file

@ -0,0 +1,10 @@
/*
* 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 type { TypeOf } from '@kbn/config-schema';
import type { unsnoozeParamsSchemaV1 } from '../..';
export type UnsnoozeParams = TypeOf<typeof unsnoozeParamsSchemaV1>;

View file

@ -5,9 +5,16 @@
* 2.0.
*/
export { unsnoozeParamsSchema, unsnoozeBodySchema } from './schemas/latest';
export {
unsnoozeParamsInternalSchema,
unsnoozeBodyInternalSchema,
} from './internal/schemas/latest';
export { unsnoozeParamsSchema } from './external/schemas/latest';
export type { UnsnoozeParams } from './external/types/latest';
export {
unsnoozeParamsSchema as unsnoozeParamsSchemaV1,
unsnoozeBodySchema as unsnoozeBodySchemaV1,
} from './schemas/v1';
unsnoozeParamsInternalSchema as unsnoozeParamsInternalSchemaV1,
unsnoozeBodyInternalSchema as unsnoozeBodyInternalSchemaV1,
} from './internal/schemas/v1';
export { unsnoozeParamsSchema as unsnoozeParamsSchemaV1 } from './external/schemas/v1';
export type { UnsnoozeParams as UnsnoozeParamsV1 } from './external/types/v1';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './v1';

View file

@ -7,12 +7,12 @@
import { schema } from '@kbn/config-schema';
export const unsnoozeParamsSchema = schema.object({
export const unsnoozeParamsInternalSchema = schema.object({
id: schema.string(),
});
const scheduleIdsSchema = schema.maybe(schema.arrayOf(schema.string()));
export const unsnoozeBodySchema = schema.object({
export const unsnoozeBodyInternalSchema = schema.object({
schedule_ids: scheduleIdsSchema,
});

View file

@ -0,0 +1,71 @@
/*
* 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 type { RulesClientContext } from '../../../../rules_client';
import { unsnoozeRule } from './unsnooze_rule';
import { savedObjectsRepositoryMock } from '@kbn/core-saved-objects-api-server-mocks';
const loggerErrorMock = jest.fn();
const getBulkMock = jest.fn();
const savedObjectsMock = savedObjectsRepositoryMock.create();
savedObjectsMock.get = jest.fn().mockReturnValue({
attributes: {
actions: [],
snoozeSchedule: [
{
duration: 600000,
rRule: {
interval: 1,
freq: 3,
dtstart: '2025-03-01T06:30:37.011Z',
tzid: 'UTC',
},
id: 'snooze_schedule_1',
},
],
},
version: '9.0.0',
});
const context = {
logger: { error: loggerErrorMock },
getActionsClient: () => {
return {
getBulk: getBulkMock,
};
},
unsecuredSavedObjectsClient: savedObjectsMock,
authorization: { ensureAuthorized: async () => {} },
ruleTypeRegistry: {
ensureRuleTypeEnabled: () => {},
},
getUserName: async () => {},
} as unknown as RulesClientContext;
describe('validate unsnooze params', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should validate params correctly', async () => {
await expect(
unsnoozeRule(context, { id: '123', scheduleIds: ['snooze_schedule_1'] })
).resolves.toBeUndefined();
});
it('should validate params with empty schedule ids correctly', async () => {
await expect(unsnoozeRule(context, { id: '123', scheduleIds: [] })).resolves.toBeUndefined();
});
it('should throw bad request for invalid params', async () => {
// @ts-expect-error: testing invalid params
await expect(unsnoozeRule(context, {})).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error validating unsnooze params - [id]: expected value of type [string] but got [undefined]"`
);
});
});

View file

@ -38,7 +38,7 @@ import { unmuteAlertRoute } from './rule/apis/unmute_alert/unmute_alert_route';
import { updateRuleApiKeyRoute } from './rule/apis/update_api_key/update_rule_api_key_route';
import { bulkEditInternalRulesRoute } from './rule/apis/bulk_edit/bulk_edit_rules_route';
import { snoozeRuleInternalRoute, snoozeRuleRoute } from './rule/apis/snooze';
import { unsnoozeRuleRoute } from './rule/apis/unsnooze';
import { unsnoozeRuleRoute, unsnoozeRuleInternalRoute } from './rule/apis/unsnooze';
import { runSoonRoute } from './run_soon';
import { bulkDeleteRulesRoute } from './rule/apis/bulk_delete/bulk_delete_rules_route';
import { bulkEnableRulesRoute } from './rule/apis/bulk_enable/bulk_enable_rules_route';
@ -124,6 +124,7 @@ export function defineRoutes(opts: RouteOptions) {
snoozeRuleInternalRoute(router, licenseState);
snoozeRuleRoute(router, licenseState);
unsnoozeRuleRoute(router, licenseState);
unsnoozeRuleInternalRoute(router, licenseState);
cloneRuleRoute(router, licenseState);
getRuleTagsRoute(router, licenseState);
registerRulesValueSuggestionsRoute(router, licenseState, config$!);

View file

@ -0,0 +1,172 @@
/*
* 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 { unsnoozeRuleRoute } from './unsnooze_rule_route';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../../../../../lib/license_state.mock';
import { mockHandlerArguments } from '../../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../../rules_client.mock';
import { RuleTypeDisabledError } from '../../../../../lib/errors/rule_type_disabled';
import type { SanitizedRule } from '../../../../../types';
const rulesClient = rulesClientMock.create();
jest.mock('../../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
describe('unsnoozeAlertRoute', () => {
const mockedAlert: SanitizedRule<{
bar: boolean;
}> = {
id: '1',
alertTypeId: '1',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date('2020-08-20T19:23:38Z'),
updatedAt: new Date('2020-08-20T19:23:38Z'),
actions: [],
snoozeSchedule: [
{
id: 'snooze_schedule_1',
duration: 600000,
rRule: {
interval: 1,
freq: 3,
dtstart: '2025-03-01T06:30:37.011Z',
tzid: 'UTC',
},
},
],
consumer: 'bar',
name: 'abc',
tags: ['foo'],
enabled: true,
muteAll: false,
notifyWhen: 'onActionGroupChange',
createdBy: '',
updatedBy: '',
apiKeyOwner: '',
throttle: '30s',
mutedInstanceIds: [],
executionStatus: {
status: 'unknown',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
},
revision: 0,
};
it('unsnoozes a rule', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
unsnoozeRuleRoute(router, licenseState);
const [config, handler] = router.delete.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(
`"/api/alerting/rule/{ruleId}/snooze_schedule/{scheduleId}"`
);
rulesClient.get.mockResolvedValueOnce(mockedAlert);
rulesClient.unsnooze.mockResolvedValueOnce();
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
params: {
ruleId: 'rule_1',
scheduleId: 'snooze_schedule_1',
},
},
['noContent']
);
expect(await handler(context, req, res)).toEqual(undefined);
expect(rulesClient.unsnooze).toHaveBeenCalledTimes(1);
expect(rulesClient.unsnooze.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"id": "rule_1",
"scheduleIds": Array [
"snooze_schedule_1",
],
},
]
`);
expect(res.noContent).toHaveBeenCalled();
});
it('ensures the rule type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
rulesClient.get.mockResolvedValueOnce(mockedAlert);
unsnoozeRuleRoute(router, licenseState);
const [, handler] = router.delete.mock.calls[0];
rulesClient.unsnooze.mockRejectedValue(new RuleTypeDisabledError('Fail', 'license_invalid'));
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{ params: { ruleId: 'rule_1', scheduleId: 'snooze_schedule_1' }, body: {} },
['ok', 'forbidden']
);
await handler(context, req, res);
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
it('should throw error when snooze schedule is empty', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
rulesClient.get.mockResolvedValueOnce({ ...mockedAlert, snoozeSchedule: [] });
unsnoozeRuleRoute(router, licenseState);
const [, handler] = router.delete.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{ params: { ruleId: 'rule_1', scheduleId: 'snooze_schedule_1' }, body: {} },
['ok', 'forbidden']
);
await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(
`[Error: Rule has no snooze schedules.]`
);
});
it('should throw error for invalid snooze schedule id', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
rulesClient.get.mockResolvedValueOnce(mockedAlert);
unsnoozeRuleRoute(router, licenseState);
const [, handler] = router.delete.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{ params: { ruleId: 'rule_1', scheduleId: 'random_schedule_1' }, body: {} },
['ok', 'forbidden']
);
await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(
`[Error: Rule has no snooze schedule with id random_schedule_1.]`
);
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import Boom from '@hapi/boom';
import type { IRouter } from '@kbn/core/server';
import {
unsnoozeParamsSchema,
type UnsnoozeParams,
} from '../../../../../../common/routes/rule/apis/unsnooze';
import type { ILicenseState } from '../../../../../lib';
import { RuleMutedError } from '../../../../../lib';
import { verifyAccessAndContext } from '../../../../lib';
import type { AlertingRequestHandlerContext } from '../../../../../types';
import { BASE_ALERTING_API_PATH } from '../../../../../types';
import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants';
export const unsnoozeRuleRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.delete(
{
path: `${BASE_ALERTING_API_PATH}/rule/{ruleId}/snooze_schedule/{scheduleId}`,
security: DEFAULT_ALERTING_ROUTE_SECURITY,
options: {
access: 'public',
summary: 'Delete a snooze schedule for a rule',
tags: ['oas-tag:alerting'],
availability: {
since: '8.19.0',
stability: 'stable',
},
},
validate: {
request: {
params: unsnoozeParamsSchema,
},
response: {
204: {
description: 'Indicates a successful call.',
},
400: {
description: 'Indicates an invalid schema.',
},
403: {
description: 'Indicates that this call is forbidden.',
},
404: {
description: 'Indicates a rule with the given id does not exist.',
},
},
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const alertingContext = await context.alerting;
const rulesClient = await alertingContext.getRulesClient();
const { ruleId, scheduleId }: UnsnoozeParams = req.params;
try {
const currentRule = await rulesClient.get({ id: ruleId });
if (!currentRule.snoozeSchedule?.length) {
throw Boom.badRequest('Rule has no snooze schedules.');
}
const scheduleToUnsnooze = currentRule.snoozeSchedule?.find(
(schedule) => schedule.id === scheduleId
);
if (!scheduleToUnsnooze) {
throw Boom.notFound(`Rule has no snooze schedule with id ${scheduleId}.`);
}
await rulesClient.unsnooze({ id: ruleId, scheduleIds: [scheduleId] });
return res.noContent();
} catch (e) {
if (e instanceof RuleMutedError) {
return e.sendResponse(res);
}
throw e;
}
})
)
);
};

View file

@ -5,4 +5,5 @@
* 2.0.
*/
export { unsnoozeRuleRoute } from './unsnooze_rule_route';
export { unsnoozeRuleRoute as unsnoozeRuleInternalRoute } from './internal/unsnooze_rule_route';
export { unsnoozeRuleRoute } from './external/unsnooze_rule_route';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './v1';

View file

@ -7,13 +7,13 @@
import { unsnoozeRuleRoute } from './unsnooze_rule_route';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../../../../lib/license_state.mock';
import { mockHandlerArguments } from '../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../rules_client.mock';
import { RuleTypeDisabledError } from '../../../../lib/errors/rule_type_disabled';
import { licenseStateMock } from '../../../../../lib/license_state.mock';
import { mockHandlerArguments } from '../../../../_mock_handler_arguments';
import { rulesClientMock } from '../../../../../rules_client.mock';
import { RuleTypeDisabledError } from '../../../../../lib/errors/rule_type_disabled';
const rulesClient = rulesClientMock.create();
jest.mock('../../../../lib/license_api_access', () => ({
jest.mock('../../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));

View file

@ -8,18 +8,18 @@
import type { TypeOf } from '@kbn/config-schema';
import type { IRouter } from '@kbn/core/server';
import {
unsnoozeBodySchema,
unsnoozeParamsSchema,
} from '../../../../../common/routes/rule/apis/unsnooze';
import type { ILicenseState } from '../../../../lib';
import { RuleMutedError } from '../../../../lib';
import { verifyAccessAndContext } from '../../../lib';
import type { AlertingRequestHandlerContext } from '../../../../types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types';
unsnoozeBodyInternalSchema,
unsnoozeParamsInternalSchema,
} from '../../../../../../common/routes/rule/apis/unsnooze';
import type { ILicenseState } from '../../../../../lib';
import { RuleMutedError } from '../../../../../lib';
import { verifyAccessAndContext } from '../../../../lib';
import type { AlertingRequestHandlerContext } from '../../../../../types';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../../../types';
import { transformUnsnoozeBodyV1 } from './transforms';
import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../constants';
import { DEFAULT_ALERTING_ROUTE_SECURITY } from '../../../../constants';
export type UnsnoozeRuleRequestParamsV1 = TypeOf<typeof unsnoozeParamsSchema>;
export type UnsnoozeRuleRequestParamsV1 = TypeOf<typeof unsnoozeParamsInternalSchema>;
export const unsnoozeRuleRoute = (
router: IRouter<AlertingRequestHandlerContext>,
@ -31,8 +31,8 @@ export const unsnoozeRuleRoute = (
security: DEFAULT_ALERTING_ROUTE_SECURITY,
options: { access: 'internal' },
validate: {
params: unsnoozeParamsSchema,
body: unsnoozeBodySchema,
params: unsnoozeParamsInternalSchema,
body: unsnoozeBodyInternalSchema,
},
},
router.handleLegacyErrors(

View file

@ -153,7 +153,7 @@ export class AlertUtils {
return request;
}
public getUnsnoozeRequest(alertId: string) {
public getUnsnoozeInternalRequest(alertId: string) {
const request = this.supertestWithoutAuth
.post(`${getUrlPrefix(this.space.id)}/internal/alerting/rule/${alertId}/_unsnooze`)
.set('kbn-xsrf', 'foo')
@ -167,6 +167,19 @@ export class AlertUtils {
return request;
}
public getUnsnoozeRequest(alertId: string, scheduleId: string) {
const request = this.supertestWithoutAuth
.delete(
`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/snooze_schedule/${scheduleId}`
)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json');
if (this.user) {
return request.auth(this.user.username, this.user.password);
}
return request;
}
public getMuteAllRequest(alertId: string) {
const request = this.supertestWithoutAuth
.post(`${getUrlPrefix(this.space.id)}/api/alerting/rule/${alertId}/_mute_all`)

View file

@ -28,6 +28,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./snooze'));
loadTestFile(require.resolve('./snooze_internal'));
loadTestFile(require.resolve('./unsnooze'));
loadTestFile(require.resolve('./unsnooze_internal'));
loadTestFile(require.resolve('./global_execution_log'));
loadTestFile(require.resolve('./get_global_execution_kpi'));
loadTestFile(require.resolve('./get_action_error_log'));

View file

@ -22,6 +22,7 @@ import {
export default function createUnsnoozeRuleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const NOW = new Date().toISOString();
describe('unsnooze', () => {
const objectRemover = new ObjectRemover(supertest);
@ -62,11 +63,38 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id);
const { body: snoozeSchedule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdAlert.id}/snooze_schedule`)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json')
.send({
schedule: {
custom: {
duration: '240h',
start: NOW,
recurring: {
occurrences: 1,
},
},
},
})
.expect(200);
const response = await alertUtils.getUnsnoozeRequest(
createdAlert.id,
snoozeSchedule.schedule.id
);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage('get', 'test.noop', 'alertsFixture'),
statusCode: 403,
});
break;
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
@ -122,14 +150,45 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id);
const { body: snoozeSchedule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdAlert.id}/snooze_schedule`)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json')
.send({
schedule: {
custom: {
duration: '240h',
start: NOW,
recurring: {
occurrences: 1,
},
},
},
})
.expect(200);
const response = await alertUtils.getUnsnoozeRequest(
createdAlert.id,
snoozeSchedule.schedule.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 at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage(
'get',
'test.restricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
break;
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
@ -179,11 +238,42 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id);
const { body: snoozeSchedule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdAlert.id}/snooze_schedule`)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json')
.send({
schedule: {
custom: {
duration: '240h',
start: NOW,
recurring: {
occurrences: 1,
},
},
},
})
.expect(200);
const response = await alertUtils.getUnsnoozeRequest(
createdAlert.id,
snoozeSchedule.schedule.id
);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage(
'get',
'test.unrestricted-noop',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
@ -236,7 +326,27 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id);
const { body: snoozeSchedule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdAlert.id}/snooze_schedule`)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json')
.send({
schedule: {
custom: {
duration: '240h',
start: NOW,
recurring: {
occurrences: 1,
},
},
},
})
.expect(200);
const response = await alertUtils.getUnsnoozeRequest(
createdAlert.id,
snoozeSchedule.schedule.id
);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
@ -244,13 +354,20 @@ export default function createUnsnoozeRuleTests({ getService }: FtrProviderConte
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage('unsnooze', 'test.restricted-noop', 'alerts'),
message: getUnauthorizedErrorMessage('get', 'test.restricted-noop', 'alerts'),
statusCode: 403,
});
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage('get', 'test.restricted-noop', 'alerts'),
statusCode: 403,
});
break;
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',

View file

@ -0,0 +1,287 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server';
import { UserAtSpaceScenarios } from '../../../scenarios';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
AlertUtils,
checkAAD,
getUrlPrefix,
getTestRuleData,
ObjectRemover,
getUnauthorizedErrorMessage,
} from '../../../../common/lib';
// eslint-disable-next-line import/no-default-export
export default function createUnsnoozeRuleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('unsnooze_internal', () => {
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 handle unsnooze rule request appropriately', 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: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeInternalRequest(createdAlert.id);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage('unsnooze', 'test.noop', 'alertsFixture'),
statusCode: 403,
});
break;
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: `Unauthorized to execute actions`,
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_schedule).to.eql([]);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: RULE_SAVED_OBJECT_TYPE,
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle unsnooze rule request appropriately when consumer is the same as producer', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
rule_type_id: 'test.restricted-noop',
consumer: 'alertsRestrictedFixture',
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeInternalRequest(createdAlert.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 at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage(
'unsnooze',
'test.restricted-noop',
'alertsRestrictedFixture'
),
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_schedule).to.eql([]);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: RULE_SAVED_OBJECT_TYPE,
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle unsnooze rule request appropriately when consumer is not the producer', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
rule_type_id: 'test.unrestricted-noop',
consumer: 'alertsFixture',
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeInternalRequest(createdAlert.id);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage(
'unsnooze',
'test.unrestricted-noop',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_schedule).to.eql([]);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: RULE_SAVED_OBJECT_TYPE,
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should handle unsnooze rule request appropriately when consumer is "alerts"', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
rule_type_id: 'test.restricted-noop',
consumer: 'alerts',
})
)
.expect(200);
objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeInternalRequest(createdAlert.id);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage('unsnooze', 'test.restricted-noop', 'alerts'),
statusCode: 403,
});
break;
case 'global_read at space1':
case 'space_1_all at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getUnauthorizedErrorMessage('unsnooze', 'test.restricted-noop', 'alerts'),
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all_with_restricted_fixture at space1':
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.expect(200);
expect(updatedAlert.snooze_schedule).to.eql([]);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: space.id,
type: RULE_SAVED_OBJECT_TYPE,
id: createdAlert.id,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -23,6 +23,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./snooze_internal'));
loadTestFile(require.resolve('./snooze'));
loadTestFile(require.resolve('./unsnooze'));
loadTestFile(require.resolve('./unsnooze_internal'));
loadTestFile(require.resolve('./bulk_edit'));
loadTestFile(require.resolve('./bulk_disable'));
loadTestFile(require.resolve('./capped_action_type'));

View file

@ -21,6 +21,7 @@ import {
export default function createSnoozeRuleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const NOW = new Date().toISOString();
describe('unsnooze', function () {
this.tags('skipFIPS');
@ -60,7 +61,29 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id);
const { body: snoozeSchedule } = await supertest
.post(
`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${createdAlert.id}/snooze_schedule`
)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json')
.send({
schedule: {
custom: {
duration: '240h',
start: NOW,
recurring: {
occurrences: 1,
},
},
},
})
.expect(200);
const response = await alertUtils.getUnsnoozeRequest(
createdAlert.id,
snoozeSchedule.schedule.id
);
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
@ -79,5 +102,65 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext
id: createdAlert.id,
});
});
describe('validation', function () {
this.tags('skipFIPS');
it('should return 400 for when rule has no snooze schedule', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [],
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id, 'random_id');
expect(response.statusCode).to.eql(400);
expect(response.body.message).to.eql('Rule has no snooze schedules.');
});
it('should return 404 for when invalid snooze schedule id', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [],
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
await supertest
.post(
`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${createdAlert.id}/snooze_schedule`
)
.set('kbn-xsrf', 'foo')
.set('content-type', 'application/json')
.send({
schedule: {
custom: {
duration: '240h',
start: NOW,
recurring: {
occurrences: 1,
},
},
},
})
.expect(200);
const response = await alertUtils.getUnsnoozeRequest(createdAlert.id, 'random_id');
expect(response.statusCode).to.eql(404);
expect(response.body.message).to.eql('Rule has no snooze schedule with id random_id.');
});
});
});
}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server';
import { Spaces } from '../../../scenarios';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
AlertUtils,
checkAAD,
getUrlPrefix,
getTestRuleData,
ObjectRemover,
} from '../../../../common/lib';
// eslint-disable-next-line import/no-default-export
export default function createSnoozeRuleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('unsnooze_internal', function () {
this.tags('skipFIPS');
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
const alertUtils = new AlertUtils({ space: Spaces.space1, supertestWithoutAuth });
it('should handle unsnooze rule request appropriately', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const response = await alertUtils.getUnsnoozeInternalRequest(createdAlert.id);
expect(response.statusCode).to.eql(204);
expect(response.body).to.eql('');
const { body: updatedAlert } = await supertestWithoutAuth
.get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.expect(200);
expect(updatedAlert.is_snoozed_until).to.eql(null);
expect(updatedAlert.snooze_schedule.length).to.eql(0);
expect(updatedAlert.mute_all).to.eql(false);
// Ensure AAD isn't broken
await checkAAD({
supertest,
spaceId: Spaces.space1.id,
type: RULE_SAVED_OBJECT_TYPE,
id: createdAlert.id,
});
});
});
}