[8.17] [ResponseOps][Rules] Validate timezone in rule routes (#201508) (#208299)

# Backport

This will backport the following commits from `main` to `8.17`:
- [[ResponseOps][Rules] Validate timezone in rule routes
(#201508)](https://github.com/elastic/kibana/pull/201508)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Christos
Nasikas","email":"christos.nasikas@elastic.co"},"sourceCommit":{"committedDate":"2025-01-24T17:46:24Z","message":"[ResponseOps][Rules]
Validate timezone in rule routes (#201508)\n\n## Summary\r\n\r\nThis PR
adds validation only for internal routes that use the
`rRule`\r\nschema.\r\n\r\n## Testing\r\n\r\n1. Create a rule in
main.\r\n2. Snooze the rule by using the API as\r\n\r\n```\r\nPOST
/internal/alerting/rule/<ruleId>/_snooze\r\n{\r\n \"snooze_schedule\":
{\r\n \"id\": \"e58e2340-dba6-454c-8308-b2ca66a7cf7b\",\r\n
\"duration\": 86400000,\r\n \"rRule\": {\r\n \"dtstart\":
\"2024-09-04T09:27:37.011Z\",\r\n \"tzid\": \"invalid\",\r\n \"freq\":
2,\r\n \"interval\": 1,\r\n \"byweekday\": [\r\n \"invalid\"\r\n ]\r\n
}\r\n }\r\n}\r\n```\r\n\r\n4. Go to the rules page and verify that the
rules are not loaded.\r\n5. Switch to my PR.\r\n6. Go to the rules page
and verify that the rules load.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"9a3fc89629e1a6cec2f5200bb75099fcab866701","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","v9.0.0","Feature:Alerting/RulesFramework","backport:prev-major","v8.18.0","v8.16.4","v8.17.2"],"title":"[ResponseOps][Rules]
Validate timezone in rule
routes","number":201508,"url":"https://github.com/elastic/kibana/pull/201508","mergeCommit":{"message":"[ResponseOps][Rules]
Validate timezone in rule routes (#201508)\n\n## Summary\r\n\r\nThis PR
adds validation only for internal routes that use the
`rRule`\r\nschema.\r\n\r\n## Testing\r\n\r\n1. Create a rule in
main.\r\n2. Snooze the rule by using the API as\r\n\r\n```\r\nPOST
/internal/alerting/rule/<ruleId>/_snooze\r\n{\r\n \"snooze_schedule\":
{\r\n \"id\": \"e58e2340-dba6-454c-8308-b2ca66a7cf7b\",\r\n
\"duration\": 86400000,\r\n \"rRule\": {\r\n \"dtstart\":
\"2024-09-04T09:27:37.011Z\",\r\n \"tzid\": \"invalid\",\r\n \"freq\":
2,\r\n \"interval\": 1,\r\n \"byweekday\": [\r\n \"invalid\"\r\n ]\r\n
}\r\n }\r\n}\r\n```\r\n\r\n4. Go to the rules page and verify that the
rules are not loaded.\r\n5. Switch to my PR.\r\n6. Go to the rules page
and verify that the rules load.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"9a3fc89629e1a6cec2f5200bb75099fcab866701"}},"sourceBranch":"main","suggestedTargetBranches":["8.16","8.17"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/201508","number":201508,"mergeCommit":{"message":"[ResponseOps][Rules]
Validate timezone in rule routes (#201508)\n\n## Summary\r\n\r\nThis PR
adds validation only for internal routes that use the
`rRule`\r\nschema.\r\n\r\n## Testing\r\n\r\n1. Create a rule in
main.\r\n2. Snooze the rule by using the API as\r\n\r\n```\r\nPOST
/internal/alerting/rule/<ruleId>/_snooze\r\n{\r\n \"snooze_schedule\":
{\r\n \"id\": \"e58e2340-dba6-454c-8308-b2ca66a7cf7b\",\r\n
\"duration\": 86400000,\r\n \"rRule\": {\r\n \"dtstart\":
\"2024-09-04T09:27:37.011Z\",\r\n \"tzid\": \"invalid\",\r\n \"freq\":
2,\r\n \"interval\": 1,\r\n \"byweekday\": [\r\n \"invalid\"\r\n ]\r\n
}\r\n }\r\n}\r\n```\r\n\r\n4. Go to the rules page and verify that the
rules are not loaded.\r\n5. Switch to my PR.\r\n6. Go to the rules page
and verify that the rules load.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"9a3fc89629e1a6cec2f5200bb75099fcab866701"}},{"branch":"8.x","label":"v8.18.0","branchLabelMappingKey":"^v8.18.0$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/208252","number":208252,"state":"OPEN"},{"branch":"8.16","label":"v8.16.4","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.17","label":"v8.17.2","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Christos Nasikas 2025-01-28 09:01:19 +01:00 committed by GitHub
parent 4b08613a54
commit d2a00e82c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1575 additions and 154 deletions

View file

@ -9,13 +9,16 @@
import moment, { type Moment } from 'moment-timezone';
import { Frequency, Weekday, type WeekdayStr, type Options, type IterOptions } from './types';
import {
Frequency,
Weekday,
type WeekdayStr,
type Options,
type IterOptions,
ConstructorOptions,
} from './types';
import { sanitizeOptions } from './sanitize';
type ConstructorOptions = Omit<Options, 'byweekday' | 'wkst'> & {
byweekday?: Array<string | number> | null;
wkst?: Weekday | WeekdayStr | number | null;
};
import { validateOptions } from './validate';
const ISO_WEEKDAYS = [
Weekday.MO,
@ -36,6 +39,7 @@ const TIMEOUT_LIMIT = 100000;
export class RRule {
private options: Options;
constructor(options: ConstructorOptions) {
this.options = sanitizeOptions(options as Options);
if (typeof options.wkst === 'string') {
@ -134,6 +138,16 @@ export class RRule {
return dates;
}
}
static isValid(options: ConstructorOptions): boolean {
try {
validateOptions(options);
return true;
} catch (e) {
return false;
}
}
}
const parseByWeekdayPos = function (byweekday: ConstructorOptions['byweekday']) {

View file

@ -26,7 +26,7 @@ describe('sanitizeOptions', () => {
interval: 1,
until: new Date('February 25, 2022 03:24:00'),
count: 3,
tzid: 'foobar',
tzid: 'UTC',
};
it('happy path', () => {
@ -41,11 +41,18 @@ describe('sanitizeOptions', () => {
});
it('throws an error when tzid is missing', () => {
expect(() => sanitizeOptions({ ...options, tzid: '' })).toThrowError(
// @ts-expect-error
expect(() => sanitizeOptions({ ...options, tzid: null })).toThrowError(
'Cannot create RRule: tzid is required'
);
});
it('throws an error when tzid is invalid', () => {
expect(() => sanitizeOptions({ ...options, tzid: 'invalid' })).toThrowError(
'Cannot create RRule: tzid is invalid'
);
});
it('throws an error when until field is invalid', () => {
expect(() =>
sanitizeOptions({

View file

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import moment from 'moment-timezone';
import type { Options } from './types';
export function sanitizeOptions(opts: Options) {
@ -25,6 +26,10 @@ export function sanitizeOptions(opts: Options) {
throw new Error('Cannot create RRule: dtstart is an invalid date');
}
if (moment.tz.zone(options.tzid) == null) {
throw new Error('Cannot create RRule: tzid is invalid');
}
if (options.until && isNaN(options.until.getTime())) {
throw new Error('Cannot create RRule: until is an invalid date');
}
@ -39,7 +44,6 @@ export function sanitizeOptions(opts: Options) {
}
}
// Omit invalid options
if (options.bymonth) {
// Only months between 1 and 12 are valid
options.bymonth = options.bymonth.filter(

View file

@ -52,3 +52,8 @@ export type Options = Omit<IterOptions, 'refDT'> & {
count?: number;
tzid: string;
};
export type ConstructorOptions = Omit<Options, 'byweekday' | 'wkst'> & {
byweekday?: Array<string | number> | null;
wkst?: Weekday | WeekdayStr | number | null;
};

View file

@ -0,0 +1,215 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { validateOptions } from './validate';
import { Weekday, Frequency, type ConstructorOptions } from './types';
describe('validateOptions', () => {
const options: ConstructorOptions = {
wkst: Weekday.MO,
byyearday: [1, 2, 3],
bymonth: [1],
bysetpos: [1],
bymonthday: [1],
byweekday: [Weekday.MO],
byhour: [1],
byminute: [1],
bysecond: [1],
dtstart: new Date('September 3, 1998 03:24:00'),
freq: Frequency.YEARLY,
interval: 1,
until: new Date('February 25, 2022 03:24:00'),
count: 3,
tzid: 'UTC',
};
it('happy path', () => {
expect(() => validateOptions(options)).not.toThrow();
});
describe('dtstart', () => {
it('throws an error when dtstart is missing', () => {
expect(() =>
// @ts-expect-error
validateOptions({ ...options, dtstart: null })
).toThrowErrorMatchingInlineSnapshot(`"dtstart is required"`);
});
it('throws an error when dtstart is not a valid date', () => {
expect(() =>
validateOptions({ ...options, dtstart: new Date('invalid') })
).toThrowErrorMatchingInlineSnapshot(`"dtstart is an invalid date"`);
});
});
describe('tzid', () => {
it('throws an error when tzid is missing', () => {
// @ts-expect-error
expect(() => validateOptions({ ...options, tzid: null })).toThrowErrorMatchingInlineSnapshot(
`"tzid is required"`
);
});
it('throws an error when tzid is invalid', () => {
expect(() =>
validateOptions({ ...options, tzid: 'invalid' })
).toThrowErrorMatchingInlineSnapshot(`"tzid is an invalid timezone"`);
});
});
describe('interval', () => {
it('throws an error when count is not a number', () => {
expect(() =>
// @ts-expect-error
validateOptions({ ...options, interval: 'invalid' })
).toThrowErrorMatchingInlineSnapshot(`"interval must be an integer greater than 0"`);
});
it('throws an error when interval is not an integer', () => {
expect(() =>
validateOptions({ ...options, interval: 1.5 })
).toThrowErrorMatchingInlineSnapshot(`"interval must be an integer greater than 0"`);
});
it('throws an error when interval is <= 0', () => {
expect(() => validateOptions({ ...options, interval: 0 })).toThrowErrorMatchingInlineSnapshot(
`"interval must be an integer greater than 0"`
);
});
});
describe('until', () => {
it('throws an error when until field is an invalid date', () => {
expect(() =>
validateOptions({
...options,
until: new Date('invalid'),
})
).toThrowErrorMatchingInlineSnapshot(`"until is an invalid date"`);
});
});
describe('count', () => {
it('throws an error when count is not a number', () => {
expect(() =>
// @ts-expect-error
validateOptions({ ...options, count: 'invalid' })
).toThrowErrorMatchingInlineSnapshot(`"count must be an integer greater than 0"`);
});
it('throws an error when count is not an integer', () => {
expect(() => validateOptions({ ...options, count: 1.5 })).toThrowErrorMatchingInlineSnapshot(
`"count must be an integer greater than 0"`
);
});
it('throws an error when count is <= 0', () => {
expect(() => validateOptions({ ...options, count: 0 })).toThrowErrorMatchingInlineSnapshot(
`"count must be an integer greater than 0"`
);
});
});
describe('bymonth', () => {
it('throws an error with out of range values', () => {
expect(() =>
validateOptions({ ...options, bymonth: [0, 6, 13] })
).toThrowErrorMatchingInlineSnapshot(
`"bymonth must be an array of numbers between 1 and 12"`
);
});
it('throws an error with string values', () => {
expect(() =>
// @ts-expect-error
validateOptions({ ...options, bymonth: ['invalid'] })
).toThrowErrorMatchingInlineSnapshot(
`"bymonth must be an array of numbers between 1 and 12"`
);
});
it('throws an error when is empty', () => {
expect(() => validateOptions({ ...options, bymonth: [] })).toThrowErrorMatchingInlineSnapshot(
`"bymonth must be an array of numbers between 1 and 12"`
);
});
});
describe('bymonthday', () => {
it('throws an error with out of range values', () => {
expect(() =>
validateOptions({ ...options, bymonthday: [0, 15, 32] })
).toThrowErrorMatchingInlineSnapshot(
`"bymonthday must be an array of numbers between 1 and 31"`
);
});
it('throws an error with string values', () => {
expect(() =>
// @ts-expect-error
validateOptions({ ...options, bymonthday: ['invalid'] })
).toThrowErrorMatchingInlineSnapshot(
`"bymonthday must be an array of numbers between 1 and 31"`
);
});
it('throws an error when is empty', () => {
expect(() =>
validateOptions({ ...options, bymonthday: [] })
).toThrowErrorMatchingInlineSnapshot(
`"bymonthday must be an array of numbers between 1 and 31"`
);
});
});
describe('byweekday', () => {
it('throws an error with out of range values when it contains only numbers', () => {
expect(() =>
validateOptions({ ...options, byweekday: [0, 4, 8] })
).toThrowErrorMatchingInlineSnapshot(`"byweekday numbers must been between 1 and 7"`);
});
it('throws an error with invalid values when it contains only string', () => {
expect(() =>
validateOptions({ ...options, byweekday: ['+1MO', 'FOO', '+3WE', 'BAR', '-4FR'] })
).toThrowErrorMatchingInlineSnapshot(`"byweekday strings must be valid weekday strings"`);
});
it('throws an error when is empty', () => {
expect(() =>
validateOptions({ ...options, byweekday: [] })
).toThrowErrorMatchingInlineSnapshot(
`"byweekday must be an array of at least one string or number"`
);
});
it('throws an error with mixed values', () => {
expect(() =>
validateOptions({ ...options, byweekday: [2, 'MO'] })
).toThrowErrorMatchingInlineSnapshot(
`"byweekday values can be either numbers or strings, not both"`
);
});
it('does not throw with properly formed byweekday strings', () => {
expect(() =>
validateOptions({
...options,
byweekday: ['+1MO', '+2TU', '+3WE', '+4TH', '-4FR', '-3SA', '-2SU', '-1MO'],
})
).not.toThrow(`"byweekday numbers must been between 1 and 7"`);
});
it('does not throw with non recurrence values', () => {
expect(() =>
validateOptions({ ...options, byweekday: ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] })
).not.toThrow(`"byweekday numbers must been between 1 and 7"`);
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import moment from 'moment-timezone';
import { ConstructorOptions } from './types';
export function validateOptions(opts: ConstructorOptions) {
const byWeekDayRegex = new RegExp('^(((\\+|-)[1-4])?(MO|TU|WE|TH|FR|SA|SU))$');
const options = { ...opts };
if (options.dtstart == null) {
throw new Error('dtstart is required');
}
if (options.tzid == null) {
throw new Error('tzid is required');
}
if (isNaN(options.dtstart.getTime())) {
throw new Error('dtstart is an invalid date');
}
if (moment.tz.zone(options.tzid) == null) {
throw new Error('tzid is an invalid timezone');
}
if (options.interval != null && (!Number.isInteger(options.interval) || options.interval < 1)) {
throw new Error('interval must be an integer greater than 0');
}
if (options.until != null && isNaN(options.until.getTime())) {
throw new Error('until is an invalid date');
}
if (options.count != null && (!Number.isInteger(options.count) || options.count < 1)) {
throw new Error('count must be an integer greater than 0');
}
if (
options.bymonthday != null &&
(options.bymonthday.length < 1 ||
options.bymonthday.some(
(monthDay) => !Number.isInteger(monthDay) || monthDay < 1 || monthDay > 31
))
) {
throw new Error('bymonthday must be an array of numbers between 1 and 31');
}
if (
options.bymonth != null &&
(options.bymonth.length < 1 ||
options.bymonth.some((month) => !Number.isInteger(month) || month < 1 || month > 12))
) {
throw new Error('bymonth must be an array of numbers between 1 and 12');
}
if (options.byweekday != null) {
if (options.byweekday.length < 1) {
throw new Error('byweekday must be an array of at least one string or number');
}
const byWeekDayNumbers = options.byweekday?.filter(
(weekDay) => typeof weekDay === 'number'
) as number[];
const byWeekDayStrings = options.byweekday?.filter(
(weekDay) => typeof weekDay === 'string'
) as string[];
if (byWeekDayNumbers.length > 0 && byWeekDayStrings.length > 0) {
throw new Error('byweekday values can be either numbers or strings, not both');
}
if (
byWeekDayNumbers.some(
(weekDayNum) => !Number.isInteger(weekDayNum) || weekDayNum < 1 || weekDayNum > 7
)
) {
throw new Error('byweekday numbers must been between 1 and 7');
}
if (byWeekDayStrings.some((weekDayStr) => !byWeekDayRegex.test(weekDayStr))) {
throw new Error('byweekday strings must be valid weekday strings');
}
}
}

View file

@ -0,0 +1,134 @@
/*
* 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 { rRuleRequestSchema } from './v1';
describe('rRuleRequestSchema', () => {
const basicRequest = {
dtstart: '2021-01-01T00:00:00Z',
tzid: 'UTC',
freq: 0,
byweekday: ['MO'],
bymonthday: [1],
bymonth: [2],
interval: 2,
until: '2021-02-01T00:00:00Z',
count: 2,
};
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2021-01-01T00:00:00Z'));
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
test('no errors on proper request', () => {
expect(rRuleRequestSchema.validate(basicRequest)).toEqual(basicRequest);
});
describe('tzid', () => {
test('returns an error with invalid values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, tzid: 'invalid' })).toThrow();
});
});
describe('interval', () => {
test('returns an error with invalid values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, interval: 'invalid' })).toThrow();
});
test('returns an error when it is a number but not an integer', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, interval: 1.5 })).toThrow();
});
test('returns an error when is set to less than one', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, interval: 0 })).toThrow();
});
});
describe('until', () => {
test('returns an error with invalid values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, until: 'invalid' })).toThrow();
});
test('returns an error if the date is in the past', () => {
expect(() =>
// one year ago
rRuleRequestSchema.validate({ ...basicRequest, until: '2020-01-01T00:00:00Z' })
).toThrow();
});
});
describe('count', () => {
test('returns an error with invalid values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, count: 'invalid' })).toThrow();
});
test('returns an error when it is a number but not an integer', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, count: 1.5 })).toThrow();
});
test('returns an error when is set to less than one', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, count: 0 })).toThrow();
});
});
describe('byweekday', () => {
test('returns an error if the are no values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, byweekday: [] })).toThrow();
});
test('returns an error with invalid values', () => {
expect(() =>
rRuleRequestSchema.validate({ ...basicRequest, byweekday: ['invalid'] })
).toThrow();
});
});
describe('bymonthday', () => {
test('returns an error if the are no values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, bymonthday: [] })).toThrow();
});
test('returns an error with invalid values', () => {
expect(() =>
rRuleRequestSchema.validate({ ...basicRequest, bymonthday: ['invalid'] })
).toThrow();
});
test('returns an error if the values are less than one', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, bymonthday: [0] })).toThrow();
});
test('returns an error if the values are bigger than 31', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, bymonthday: [32] })).toThrow();
});
});
describe('bymonth', () => {
test('returns an error if the are no values', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, bymonth: [] })).toThrow();
});
test('returns an error with invalid values', () => {
expect(() =>
rRuleRequestSchema.validate({ ...basicRequest, bymonth: ['invalid'] })
).toThrow();
});
test('returns an error if the values are less than one', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, bymonth: [0] })).toThrow();
});
test('returns an error if the values are bigger than 12', () => {
expect(() => rRuleRequestSchema.validate({ ...basicRequest, bymonth: [13] })).toThrow();
});
});
});

View file

@ -6,16 +6,16 @@
*/
import { schema } from '@kbn/config-schema';
import { validateTimezone } from '../../../rule/validation/validate_timezone/v1';
import {
validateStartDateV1,
validateEndDateV1,
createValidateRecurrenceByV1,
validateRecurrenceByWeekdayV1,
} from '../../validation';
export const rRuleRequestSchema = schema.object({
dtstart: schema.string({ validate: validateStartDateV1 }),
tzid: schema.string(),
tzid: schema.string({ validate: validateTimezone }),
freq: schema.maybe(
schema.oneOf([schema.literal(0), schema.literal(1), schema.literal(2), schema.literal(3)])
),
@ -42,17 +42,10 @@ export const rRuleRequestSchema = schema.object({
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
minSize: 1,
validate: validateRecurrenceByWeekdayV1,
})
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number({ min: 1, max: 31 }), {
validate: createValidateRecurrenceByV1('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number({ min: 1, max: 12 }), {
validate: createValidateRecurrenceByV1('bymonth'),
})
),
bymonthday: schema.maybe(schema.arrayOf(schema.number({ min: 1, max: 31 }), { minSize: 1 })),
bymonth: schema.maybe(schema.arrayOf(schema.number({ min: 1, max: 12 }), { minSize: 1 })),
});

View file

@ -7,10 +7,8 @@
export { validateStartDate } from './validate_start_date/latest';
export { validateEndDate } from './validate_end_date/latest';
export { createValidateRecurrenceBy } from './validate_recurrence_by/latest';
export { validateRecurrenceByWeekday } from './validate_recurrence_by_weekday/latest';
export { validateStartDate as validateStartDateV1 } from './validate_start_date/v1';
export { validateEndDate as validateEndDateV1 } from './validate_end_date/v1';
export { createValidateRecurrenceBy as createValidateRecurrenceByV1 } from './validate_recurrence_by/v1';
export { validateRecurrenceByWeekday as validateRecurrenceByWeekdayV1 } from './validate_recurrence_by_weekday/v1';

View file

@ -1,8 +0,0 @@
/*
* 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

@ -1,16 +0,0 @@
/*
* 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 const validateRecurrenceBy = <T>(name: string, array: T[]) => {
if (array.length === 0) {
return `rRule ${name} cannot be empty`;
}
};
export const createValidateRecurrenceBy = <T>(name: string) => {
return (array: T[]) => validateRecurrenceBy(name, array);
};

View file

@ -11,7 +11,17 @@ import { validateSnoozeScheduleV1 } from '../../validation';
export const ruleSnoozeScheduleSchema = schema.object(
{
id: schema.maybe(schema.string()),
id: schema.maybe(
schema.string({
validate: (id: string) => {
const regex = new RegExp('^[a-z0-9_-]+$', 'g');
if (!regex.test(id)) {
return `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`;
}
},
})
),
duration: schema.number(),
rRule: rRuleRequestSchemaV1,
},

View file

@ -4,12 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import 'moment-timezone';
import moment from 'moment-timezone';
export function validateTimezone(timezone: string) {
if (moment.tz.names().includes(timezone)) {
if (moment.tz.zone(timezone) != null) {
return;
}
return 'string is not a valid timezone: ' + timezone;
}

View file

@ -58,8 +58,8 @@ jest.mock('../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invali
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
jest.mock('../../../../lib/snooze/is_snooze_active', () => ({
isSnoozeActive: jest.fn(),
jest.mock('../../../../lib/snooze/get_active_snooze_if_exist', () => ({
getActiveSnoozeIfExist: jest.fn(),
}));
jest.mock('uuid', () => {
@ -71,7 +71,9 @@ jest.mock('../get_schedule_frequency', () => ({
validateScheduleLimit: jest.fn(),
}));
const { isSnoozeActive } = jest.requireMock('../../../../lib/snooze/is_snooze_active');
const { getActiveSnoozeIfExist } = jest.requireMock(
'../../../../lib/snooze/get_active_snooze_if_exist'
);
const { validateScheduleLimit } = jest.requireMock('../get_schedule_frequency');
const taskManager = taskManagerMock.createStart();
@ -1882,7 +1884,7 @@ describe('bulkEdit()', () => {
describe('snoozeSchedule operations', () => {
afterEach(() => {
isSnoozeActive.mockImplementation(() => false);
getActiveSnoozeIfExist.mockImplementation(() => false);
});
const getSnoozeSchedule = (useId: boolean = true) => {

View file

@ -0,0 +1,82 @@
/*
* 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 { RulesClientContext } from '../../../../rules_client';
import { snoozeRule } from './snooze_rule';
import { savedObjectsRepositoryMock } from '@kbn/core-saved-objects-api-server-mocks';
import { SnoozeRuleOptions } from './types';
const loggerErrorMock = jest.fn();
const getBulkMock = jest.fn();
const savedObjectsMock = savedObjectsRepositoryMock.create();
savedObjectsMock.get = jest.fn().mockReturnValue({
attributes: {
actions: [],
},
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 snooze params and body', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should throw bad request for invalid params', async () => {
const invalidParams = {
id: 22,
snoozeSchedule: getSnoozeSchedule(),
};
// @ts-expect-error: testing invalid params
await expect(snoozeRule(context, invalidParams)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error validating snooze - [id]: expected value of type [string] but got [number]"`
);
});
it('should throw bad request for invalid snooze schedule', async () => {
const invalidParams = {
id: '123',
// @ts-expect-error: testing invalid params
snoozeSchedule: getSnoozeSchedule({ rRule: { dtstart: 'invalid' } }),
};
await expect(snoozeRule(context, invalidParams)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Error validating snooze - [rRule.dtstart]: Invalid date: invalid"`
);
});
});
const getSnoozeSchedule = (
override?: SnoozeRuleOptions['snoozeSchedule']
): SnoozeRuleOptions['snoozeSchedule'] => {
return {
id: '123',
duration: 28800000,
rRule: {
dtstart: '2010-09-19T11:49:59.329Z',
count: 1,
tzid: 'UTC',
},
...override,
};
};

View file

@ -7,6 +7,7 @@
import Boom from '@hapi/boom';
import { withSpan } from '@kbn/apm-utils';
import { ruleSnoozeScheduleSchema } from '../../../../../common/routes/rule/request';
import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects';
import { getRuleSavedObject } from '../../../../rules_client/lib';
import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events';
@ -30,8 +31,9 @@ export async function snoozeRule(
): Promise<void> {
try {
snoozeRuleParamsSchema.validate({ id });
ruleSnoozeScheduleSchema.validate({ ...snoozeSchedule });
} catch (error) {
throw Boom.badRequest(`Error validating snooze params - ${error.message}`);
throw Boom.badRequest(`Error validating snooze - ${error.message}`);
}
const snoozeDateValidationMsg = validateSnoozeStartDate(snoozeSchedule.rRule.dtstart);
if (snoozeDateValidationMsg) {

View file

@ -7,7 +7,7 @@
import { first, isEmpty } from 'lodash';
import { SanitizedRule, RuleTypeParams } from '../../common/rule';
import { isSnoozeActive } from './snooze/is_snooze_active';
import { getActiveSnoozeIfExist } from './snooze/get_active_snooze_if_exist';
type RuleSnoozeProps = Pick<SanitizedRule<RuleTypeParams>, 'muteAll' | 'snoozeSchedule'>;
type ActiveSnoozes = Array<{ snoozeEndTime: Date; id: string; lastOccurrence?: Date }>;
@ -19,7 +19,7 @@ export function getActiveSnoozes(rule: RuleSnoozeProps): ActiveSnoozes | null {
return (
rule.snoozeSchedule
.map((snooze) => isSnoozeActive(snooze))
.map((snooze) => getActiveSnoozeIfExist(snooze))
.filter(Boolean)
// Sort in descending snoozeEndTime order
.sort((a, b) => b!.snoozeEndTime.getTime() - a!.snoozeEndTime.getTime()) as ActiveSnoozes

View file

@ -0,0 +1,254 @@
/*
* 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 moment from 'moment';
import { Frequency } from '@kbn/rrule';
import sinon from 'sinon';
import { RRuleRecord } from '../../types';
import { getActiveSnoozeIfExist } from './get_active_snooze_if_exist';
let fakeTimer: sinon.SinonFakeTimers;
describe('getActiveSnoozeIfExist', () => {
afterAll(() => fakeTimer.restore());
test('snooze is NOT active byweekday', () => {
// Set the current time as:
// - Feb 27 2023 08:15:00 GMT+0000 - Monday
fakeTimer = sinon.useFakeTimers(new Date('2023-02-27T08:15:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Feb 24 2023 23:00:00 GMT+0000 - Friday
// - End date of: Feb 27 2023 06:00:00 GMT+0000 - Monday
// - Which is obtained from start date + 2 days and 7 hours (198000000 ms)
const snoozeA = {
duration: 198000000,
rRule: {
byweekday: ['SA'],
tzid: 'Europe/Madrid',
freq: Frequency.DAILY,
interval: 1,
dtstart: '2023-02-24T23:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
test('snooze is active byweekday', () => {
// Set the current time as:
// - Feb 25 2023 08:15:00 GMT+0000 - Saturday
fakeTimer = sinon.useFakeTimers(new Date('2023-02-25T08:15:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Feb 24 2023 23:00:00 GMT+0000 - Friday
// - End date of: Feb 27 2023 06:00:00 GMT+0000 - Monday
// - Which is obtained from start date + 2 days and 7 hours (198000000 ms)
const snoozeA = {
duration: 198000000,
rRule: {
byweekday: ['SA'],
tzid: 'Europe/Madrid',
freq: Frequency.DAILY,
interval: 1,
dtstart: '2023-02-24T23:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`
Object {
"id": "9141dc1f-ed85-4656-91e4-119173105432",
"lastOccurrence": 2023-02-24T23:00:00.000Z,
"snoozeEndTime": 2023-02-27T06:00:00.000Z,
}
`);
fakeTimer.restore();
});
test('snooze is NOT active in recurrence byweekday', () => {
// Set the current time as:
// - March 01 2023 08:15:00 GMT+0000 - Wednesday
fakeTimer = sinon.useFakeTimers(new Date('2023-03-01T08:15:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Feb 24 2023 23:00:00 GMT+0000 - Friday
// - End date of: Feb 27 2023 06:00:00 GMT+0000 - Monday
// - Which is obtained from start date + 2 days and 7 hours (198000000 ms)
const snoozeA = {
duration: 198000000,
rRule: {
byweekday: ['SA'],
tzid: 'Europe/Madrid',
freq: Frequency.DAILY,
interval: 1,
dtstart: '2023-02-24T23:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
test('snooze is active in recurrence byweekday', () => {
// Set the current time as:
// - March 04 2023 08:15:00 GMT+0000 - Saturday
fakeTimer = sinon.useFakeTimers(new Date('2023-03-04T08:15:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Feb 24 2023 23:00:00 GMT+0000 - Friday
// - End date of: Feb 27 2023 06:00:00 GMT+0000 - Monday
// - Which is obtained from start date + 2 days and 7 hours (198000000 ms)
const snoozeA = {
duration: 198000000,
rRule: {
byweekday: ['SA'],
tzid: 'Europe/Madrid',
freq: Frequency.DAILY,
interval: 1,
dtstart: '2023-02-24T23:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`
Object {
"id": "9141dc1f-ed85-4656-91e4-119173105432",
"lastOccurrence": 2023-03-03T23:00:00.000Z,
"snoozeEndTime": 2023-03-06T06:00:00.000Z,
}
`);
fakeTimer.restore();
});
test('snooze is NOT active bymonth', () => {
// Set the current time as:
// - Feb 27 2023 08:15:00 GMT+0000 - Monday
fakeTimer = sinon.useFakeTimers(new Date('2023-02-09T08:15:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Jan 01 2023 00:00:00 GMT+0000 - Sunday
// - End date of: Jan 31 2023 06:00:00 GMT+0000 - Tuesday
// - Which is obtained from start date + 1 month (2629800000 ms)
const snoozeA = {
duration: moment('2023-01', 'YYYY-MM').daysInMonth() * 24 * 60 * 60 * 1000, // 1 month
rRule: {
freq: Frequency.YEARLY,
interval: 1,
bymonthday: [1],
bymonth: [1],
tzid: 'Europe/Madrid',
dtstart: '2023-01-01T00:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
test('snooze is active bymonth', () => {
// Set the current time as:
// - Jan 25 2023 08:15:00 GMT+0000 - Saturday
fakeTimer = sinon.useFakeTimers(new Date('2023-01-25T08:15:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Jan 01 2023 00:00:00 GMT+0000 - Sunday
// - End date of: Jan 31 2023 06:00:00 GMT+0000 - Tuesday
// - Which is obtained from start date + 1 month (2629800000 ms)
const snoozeA = {
duration: moment('2023-01', 'YYYY-MM').daysInMonth() * 24 * 60 * 60 * 1000,
rRule: {
bymonthday: [1],
bymonth: [1],
tzid: 'Europe/Madrid',
freq: Frequency.MONTHLY,
interval: 1,
dtstart: '2023-01-01T00:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`
Object {
"id": "9141dc1f-ed85-4656-91e4-119173105432",
"lastOccurrence": 2023-01-01T00:00:00.000Z,
"snoozeEndTime": 2023-02-01T00:00:00.000Z,
}
`);
fakeTimer.restore();
});
test('snooze is NOT active bymonth after the first month', () => {
// Set the current time as:
// - Feb 01 2023 00:00:00 GMT+0000 - Wednesday
fakeTimer = sinon.useFakeTimers(new Date('2023-02-01T00:00:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Jan 01 2023 00:00:00 GMT+0000 - Sunday
// - End date of: Jan 31 2023 06:00:00 GMT+0000 - Tuesday
// - Which is obtained from start date + 1 month (2629800000 ms)
const snoozeA = {
duration: moment('2023-01', 'YYYY-MM').daysInMonth() * 24 * 60 * 60 * 1000,
rRule: {
bymonthday: [1],
bymonth: [1],
tzid: 'Europe/Madrid',
freq: Frequency.MONTHLY,
interval: 1,
dtstart: '2023-01-01T00:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
// THIS is wrong, we need to do the same thing that we did for `byweekday` for
test('snooze is NOT active bymonth before the first month', () => {
// Set the current time as:
// - Dec 31 2022 23:00:00 GMT+0000 - Wednesday
fakeTimer = sinon.useFakeTimers(new Date('2022-12-31T21:00:00.000Z'));
// Try to get snooze end time with:
// - Start date of: Jan 01 2023 00:00:00 GMT+0000 - Sunday
// - End date of: Jan 31 2023 06:00:00 GMT+0000 - Tuesday
// - Which is obtained from start date + 1 month (2629800000 ms)
const snoozeA = {
duration: moment('2023-01', 'YYYY-MM').daysInMonth() * 24 * 60 * 60 * 1000,
rRule: {
bymonthday: [1],
bymonth: [1],
tzid: 'Europe/Madrid',
freq: Frequency.MONTHLY,
interval: 1,
dtstart: '2023-01-01T00:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
test('snooze still works with invalid bymonth value', () => {
// Set the current time as:
// - Feb 27 2023 08:15:00 GMT+0000 - Monday
fakeTimer = sinon.useFakeTimers(new Date('2023-02-09T08:15:00.000Z'));
const snoozeA = {
duration: moment('2023-01', 'YYYY-MM').daysInMonth() * 24 * 60 * 60 * 1000, // 1 month
rRule: {
freq: Frequency.YEARLY,
interval: 1,
bymonthday: [1],
bymonth: [0],
tzid: 'Europe/Madrid',
dtstart: '2023-01-01T00:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 { RRule, Weekday } from '@kbn/rrule';
import { RuleSnoozeSchedule } from '../../types';
const MAX_TIMESTAMP = 8640000000000000;
export function getActiveSnoozeIfExist(snooze: RuleSnoozeSchedule) {
const { duration, rRule, id } = snooze;
if (duration === -1)
return {
id,
snoozeEndTime: new Date(MAX_TIMESTAMP),
};
const startTimeMS = Date.parse(rRule.dtstart);
const initialEndTime = startTimeMS + duration;
const isInitialStartSkipped = snooze.skipRecurrences?.includes(rRule.dtstart);
// If now is during the first occurrence of the snooze
const now = Date.now();
if (now >= startTimeMS && now < initialEndTime && !isInitialStartSkipped)
return {
snoozeEndTime: new Date(initialEndTime),
lastOccurrence: new Date(rRule.dtstart),
id,
};
// Check to see if now is during a recurrence of the snooze
try {
const rRuleOptions = {
...rRule,
dtstart: new Date(rRule.dtstart),
until: rRule.until ? new Date(rRule.until) : null,
byweekday: rRule.byweekday ?? null,
wkst: rRule.wkst ? Weekday[rRule.wkst] : null,
};
const recurrenceRule = new RRule(rRuleOptions);
const lastOccurrence = recurrenceRule.before(new Date(now));
if (!lastOccurrence) return null;
// Check if the current recurrence has been skipped manually
if (snooze.skipRecurrences?.includes(lastOccurrence.toISOString())) return null;
const lastOccurrenceEndTime = lastOccurrence.getTime() + duration;
if (now < lastOccurrenceEndTime)
return { lastOccurrence, snoozeEndTime: new Date(lastOccurrenceEndTime), id };
} catch (e) {
return null;
}
return null;
}

View file

@ -5,5 +5,5 @@
* 2.0.
*/
export { isSnoozeActive } from './is_snooze_active';
export { getActiveSnoozeIfExist } from './get_active_snooze_if_exist';
export { isSnoozeExpired } from './is_snooze_expired';

View file

@ -9,11 +9,11 @@ import moment from 'moment';
import { Frequency } from '@kbn/rrule';
import sinon from 'sinon';
import { RRuleRecord } from '../../types';
import { isSnoozeActive } from './is_snooze_active';
import { getActiveSnoozeIfExist } from './get_active_snooze_if_exist';
let fakeTimer: sinon.SinonFakeTimers;
describe('isSnoozeActive', () => {
describe('getActiveSnoozeIfExist', () => {
afterAll(() => fakeTimer.restore());
test('snooze is NOT active byweekday', () => {
@ -36,7 +36,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
@ -60,7 +60,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`
Object {
"id": "9141dc1f-ed85-4656-91e4-119173105432",
"lastOccurrence": 2023-02-24T23:00:00.000Z,
@ -90,7 +90,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
@ -114,7 +114,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`
Object {
"id": "9141dc1f-ed85-4656-91e4-119173105432",
"lastOccurrence": 2023-03-03T23:00:00.000Z,
@ -145,7 +145,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
@ -170,7 +170,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`
Object {
"id": "9141dc1f-ed85-4656-91e4-119173105432",
"lastOccurrence": 2023-01-01T00:00:00.000Z,
@ -201,7 +201,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
@ -227,7 +227,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
@ -248,7 +248,7 @@ describe('isSnoozeActive', () => {
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
expect(getActiveSnoozeIfExist(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
});

View file

@ -10,7 +10,7 @@ import { RuleSnoozeSchedule } from '../../types';
const MAX_TIMESTAMP = 8640000000000000;
export function isSnoozeActive(snooze: RuleSnoozeSchedule) {
export function getActiveSnoozeIfExist(snooze: RuleSnoozeSchedule) {
const { duration, rRule, id } = snooze;
if (duration === -1)
return {
@ -49,7 +49,7 @@ export function isSnoozeActive(snooze: RuleSnoozeSchedule) {
if (now < lastOccurrenceEndTime)
return { lastOccurrence, snoozeEndTime: new Date(lastOccurrenceEndTime), id };
} catch (e) {
throw new Error(`Failed to process RRule ${rRule}: ${e}`);
return null;
}
return null;

View file

@ -7,10 +7,10 @@
import { RRule, Weekday } from '@kbn/rrule';
import { RuleSnoozeSchedule } from '../../types';
import { isSnoozeActive } from './is_snooze_active';
import { getActiveSnoozeIfExist } from './get_active_snooze_if_exist';
export function isSnoozeExpired(snooze: RuleSnoozeSchedule) {
if (isSnoozeActive(snooze)) {
if (getActiveSnoozeIfExist(snooze)) {
return false;
}
const now = Date.now();

View file

@ -4,12 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import 'moment-timezone';
import moment from 'moment-timezone';
export function validateTimezone(timezone: string) {
if (moment.tz.names().includes(timezone)) {
if (moment.tz.zone(timezone) != null) {
return;
}
return 'string is not a valid timezone: ' + timezone;
}

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import { EuiButtonIcon, EuiButton } from '@elastic/eui';
import React from 'react';
import { act } from 'react-dom/test-utils';
import moment from 'moment';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { render, screen, waitFor } from '@testing-library/react';
import { RulesListNotifyBadge } from './notify_badge';
import userEvent from '@testing-library/user-event';
jest.mock('../../../../../common/lib/kibana');
@ -18,13 +17,19 @@ describe('RulesListNotifyBadge', () => {
const onRuleChanged = jest.fn();
const snoozeRule = jest.fn();
const unsnoozeRule = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: 0 });
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('1990-01-01T05:00:00.000Z'));
});
afterEach(() => {
jest.clearAllMocks();
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('renders an unsnoozed badge', () => {
const wrapper = mountWithIntl(
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
@ -38,14 +43,11 @@ describe('RulesListNotifyBadge', () => {
);
// Rule without snooze
const badge = wrapper.find(EuiButtonIcon);
expect(badge.first().props().iconType).toEqual('bell');
expect(screen.getByTestId('rulesListNotifyBadge-unsnoozed')).toBeInTheDocument();
});
it('renders a snoozed badge', () => {
jest.useFakeTimers().setSystemTime(moment('1990-01-01').toDate());
const wrapper = mountWithIntl(
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
@ -58,16 +60,12 @@ describe('RulesListNotifyBadge', () => {
/>
);
const snoozeBadge = wrapper.find(EuiButton);
expect(snoozeBadge.first().props().iconType).toEqual('bellSlash');
expect(snoozeBadge.text()).toEqual('Feb 1');
expect(screen.getByTestId('rulesListNotifyBadge-snoozed')).toBeInTheDocument();
expect(screen.getByText('Feb 1')).toBeInTheDocument();
});
it('renders an indefinitely snoozed badge', () => {
jest.useFakeTimers().setSystemTime(moment('1990-01-01').toDate());
const wrapper = mountWithIntl(
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
@ -80,15 +78,11 @@ describe('RulesListNotifyBadge', () => {
/>
);
const indefiniteSnoozeBadge = wrapper.find(EuiButtonIcon);
expect(indefiniteSnoozeBadge.first().props().iconType).toEqual('bellSlash');
expect(indefiniteSnoozeBadge.text()).toEqual('');
expect(screen.getByTestId('rulesListNotifyBadge-snoozedIndefinitely')).toBeInTheDocument();
});
it('should allow the user to snooze rules', async () => {
jest.useFakeTimers().setSystemTime(moment('1990-01-01').toDate());
const wrapper = mountWithIntl(
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
@ -101,31 +95,26 @@ describe('RulesListNotifyBadge', () => {
/>
);
// Open the popover
wrapper.find(EuiButtonIcon).first().simulate('click');
await user.click(screen.getByTestId('rulesListNotifyBadge-unsnoozed'));
await user.click(await screen.findByTestId('linkSnooze1h'));
// Snooze for 1 hour
wrapper.find('button[data-test-subj="linkSnooze1h"]').first().simulate('click');
expect(snoozeRule).toHaveBeenCalledWith({
duration: 3600000,
id: null,
rRule: {
count: 1,
dtstart: '1990-01-01T05:00:00.000Z',
tzid: 'America/New_York',
},
});
await act(async () => {
jest.runOnlyPendingTimers();
await waitFor(() => {
expect(snoozeRule).toHaveBeenCalledWith({
duration: 3600000,
id: null,
rRule: {
count: 1,
dtstart: '1990-01-01T05:00:00.000Z',
tzid: 'America/New_York',
},
});
});
expect(onRuleChanged).toHaveBeenCalled();
});
it('should allow the user to unsnooze rules', async () => {
jest.useFakeTimers().setSystemTime(moment('1990-01-01').toDate());
const wrapper = mountWithIntl(
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
@ -137,16 +126,80 @@ describe('RulesListNotifyBadge', () => {
/>
);
// Open the popover
wrapper.find(EuiButtonIcon).first().simulate('click');
await user.click(screen.getByTestId('rulesListNotifyBadge-snoozedIndefinitely'));
await user.click(await screen.findByTestId('ruleSnoozeCancel'));
// Unsnooze
wrapper.find('[data-test-subj="ruleSnoozeCancel"] button').simulate('click');
await act(async () => {
jest.runOnlyPendingTimers();
await waitFor(() => {
expect(unsnoozeRule).toHaveBeenCalled();
});
});
expect(unsnoozeRule).toHaveBeenCalled();
it('renders an invalid badge with invalid schedule timezone', () => {
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
isSnoozedUntil: null,
muteAll: false,
snoozeSchedule: [
{ duration: 1, rRule: { dtstart: '1990-01-01T05:00:00.200Z', tzid: 'invalid' } },
],
}}
onRuleChanged={onRuleChanged}
snoozeRule={snoozeRule}
unsnoozeRule={unsnoozeRule}
/>
);
expect(screen.getByTestId('rulesListNotifyBadge-invalidSnooze')).toBeInTheDocument();
});
it('renders an invalid badge with invalid schedule byweekday', () => {
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
isSnoozedUntil: null,
muteAll: false,
snoozeSchedule: [
{
duration: 1,
rRule: {
dtstart: '1990-01-01T05:00:00.200Z',
tzid: 'America/New_York',
byweekday: ['invalid'],
},
},
],
}}
onRuleChanged={onRuleChanged}
snoozeRule={snoozeRule}
unsnoozeRule={unsnoozeRule}
/>
);
expect(screen.getByTestId('rulesListNotifyBadge-invalidSnooze')).toBeInTheDocument();
});
it('should clear an infinitive snooze schedule', async () => {
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
muteAll: true,
isSnoozedUntil: moment('1990-02-01').toDate(),
}}
onRuleChanged={onRuleChanged}
snoozeRule={snoozeRule}
unsnoozeRule={unsnoozeRule}
/>
);
await user.click(screen.getByTestId('rulesListNotifyBadge-snoozedIndefinitely'));
await user.click(screen.getByTestId('ruleSnoozeCancel'));
await waitFor(() => {
expect(unsnoozeRule).toHaveBeenCalledWith(undefined);
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useMemo, useState, useRef } from 'react';
import React, { useCallback, useMemo, useState, useRef, memo } from 'react';
import moment from 'moment';
import {
EuiButton,
@ -19,8 +19,11 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RRuleParams } from '@kbn/alerting-types';
import { Weekday } from '@kbn/rrule/types';
import { RRule } from '@kbn/rrule';
import { useKibana } from '../../../../../common/lib/kibana';
import { SnoozeSchedule } from '../../../../../types';
import { RuleSnoozeSettings, SnoozeSchedule } from '../../../../../types';
import { i18nAbbrMonthDayDate, i18nMonthDayDate } from '../../../../lib/i18n_month_day_date';
import { SnoozePanel, futureTimeToInterval } from '../rule_snooze';
import { getNextRuleSnoozeSchedule, isRuleSnoozed } from './helpers';
@ -30,6 +33,9 @@ import {
SNOOZE_SUCCESS_MESSAGE,
UNSNOOZE_SUCCESS_MESSAGE,
UNITS_TRANSLATION,
INVALID_SNOOZE,
INVALID_SNOOZE_TOOLTIP_TITLE,
INVALID_SNOOZE_TOOLTIP_CONTENT,
} from './translations';
import { RulesListNotifyBadgeProps } from './types';
@ -106,6 +112,11 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
return !isSnoozed && Boolean(nextScheduledSnooze);
}, [nextScheduledSnooze, isSnoozed]);
const isSnoozeValid = useMemo(
() => isSnoozeScheduleValid(snoozeSettings?.snoozeSchedule),
[snoozeSettings?.snoozeSchedule]
);
const formattedSnoozeText = useMemo(() => {
if (!isSnoozedUntil) {
if (nextScheduledSnooze)
@ -246,6 +257,24 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
);
}, [showOnHover, isLoading, isDisabled, snoozeButtonAriaLabel, isPopoverOpen, openPopover]);
const onApplyUnsnooze = useCallback(
async (scheduleIds?: string[]) => {
try {
setRequestInFlightLoading(true);
closePopover();
await unsnoozeRule(scheduleIds);
await onRuleChanged();
toasts.addSuccess(UNSNOOZE_SUCCESS_MESSAGE);
} catch (e) {
toasts.addDanger(SNOOZE_FAILED_MESSAGE);
} finally {
setRequestInFlightLoading(false);
requestAnimationFrame(() => focusTrapButtonRef.current?.focus());
}
},
[closePopover, unsnoozeRule, onRuleChanged, toasts]
);
const indefiniteSnoozeButton = useMemo(() => {
return (
<EuiButtonIcon
@ -264,6 +293,17 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
}, [isLoading, isDisabled, snoozeButtonAriaLabel, openPopover]);
const button = useMemo(() => {
if (!isSnoozeValid) {
return (
<InvalidSnoozeButton
isLoading={isLoading}
isDisabled={isDisabled}
onClick={() => onApplyUnsnooze(getSnoozeScheduleIds(snoozeSettings?.snoozeSchedule))}
ref={focusTrapButtonRef}
/>
);
}
if (isScheduled) {
return scheduledSnoozeButton;
}
@ -275,22 +315,36 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
}
return unsnoozedButton;
}, [
isSnoozed,
isSnoozeValid,
isScheduled,
isSnoozedIndefinitely,
scheduledSnoozeButton,
snoozedButton,
indefiniteSnoozeButton,
isSnoozed,
unsnoozedButton,
isLoading,
isDisabled,
onApplyUnsnooze,
snoozeSettings?.snoozeSchedule,
scheduledSnoozeButton,
indefiniteSnoozeButton,
snoozedButton,
]);
const buttonWithToolTip = useMemo(() => {
if (!isSnoozeValid) {
return (
<EuiToolTip title={INVALID_SNOOZE_TOOLTIP_TITLE} content={INVALID_SNOOZE_TOOLTIP_CONTENT}>
{button}
</EuiToolTip>
);
}
const tooltipContent =
typeof disabled === 'string'
? disabled
: isPopoverOpen || showTooltipInline
? undefined
: snoozeTimeLeft;
return (
<EuiToolTip
title={
@ -308,7 +362,7 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
{button}
</EuiToolTip>
);
}, [disabled, isPopoverOpen, button, showTooltipInline, snoozeTimeLeft]);
}, [isSnoozeValid, disabled, isPopoverOpen, showTooltipInline, snoozeTimeLeft, button]);
const onApplySnooze = useCallback(
async (schedule: SnoozeSchedule) => {
@ -328,24 +382,6 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
[closePopover, snoozeRule, onRuleChanged, toasts]
);
const onApplyUnsnooze = useCallback(
async (scheduleIds?: string[]) => {
try {
setRequestInFlightLoading(true);
closePopover();
await unsnoozeRule(scheduleIds);
await onRuleChanged();
toasts.addSuccess(UNSNOOZE_SUCCESS_MESSAGE);
} catch (e) {
toasts.addDanger(SNOOZE_FAILED_MESSAGE);
} finally {
setRequestInFlightLoading(false);
requestAnimationFrame(() => focusTrapButtonRef.current?.focus());
}
},
[closePopover, unsnoozeRule, onRuleChanged, toasts]
);
const popover = (
<EuiPopover
data-test-subj="rulesListNotifyBadge"
@ -366,6 +402,7 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
/>
</EuiPopover>
);
if (showTooltipInline) {
return (
<EuiFlexGroup alignItems="center">
@ -378,5 +415,78 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
</EuiFlexGroup>
);
}
return popover;
};
interface InvalidSnoozeButtonProps {
isLoading: boolean;
isDisabled: boolean;
ref?: React.RefObject<HTMLButtonElement>;
onClick: () => void;
}
const InvalidSnoozeButton: React.FC<InvalidSnoozeButtonProps> = memo(
({ isLoading, isDisabled, onClick, ref }) => {
return (
<EuiButton
size="s"
isLoading={isLoading}
disabled={isLoading || isDisabled}
data-test-subj="rulesListNotifyBadge-invalidSnooze"
aria-label={INVALID_SNOOZE_TOOLTIP_TITLE}
minWidth={85}
iconType="warning"
color="danger"
onClick={onClick}
buttonRef={ref}
>
<EuiText size="xs">{INVALID_SNOOZE}</EuiText>
</EuiButton>
);
}
);
const isSnoozeScheduleValid = (snoozeSchedule: RuleSnoozeSettings['snoozeSchedule']) => {
if (snoozeSchedule == null || snoozeSchedule.length === 0) {
return true;
}
return snoozeSchedule.every(isSnoozeValid);
};
const isSnoozeValid = (snooze: NonNullable<RuleSnoozeSettings['snoozeSchedule']>[number]) => {
const { rRule } = snooze;
const rRuleOptions = {
dtstart: rRule.dtstart,
tzid: rRule.tzid,
freq: rRule.freq,
interval: rRule.interval,
until: rRule.until,
count: rRule.count,
byweekday: rRule.byweekday,
bymonthday: rRule.bymonthday,
bymonth: rRule.bymonth,
};
return isValidateRRule(rRuleOptions);
};
const getSnoozeScheduleIds = (snooze: NonNullable<RuleSnoozeSettings['snoozeSchedule']> = []) => {
return snooze.map(({ id }) => id).filter(Boolean) as string[];
};
const isValidateRRule = (rRule: RRuleParams): boolean => {
const { dtstart, until, wkst, byweekday, ...rest } = rRule;
const rRuleOptions = {
...rest,
dtstart: new Date(rRule.dtstart),
until: until ? new Date(until) : null,
wkst: wkst ? Weekday[wkst] : null,
byweekday: byweekday ?? null,
};
return RRule.isValid(rRuleOptions);
};

View file

@ -28,6 +28,27 @@ export const SNOOZE_FAILED_MESSAGE = i18n.translate(
}
);
export const INVALID_SNOOZE = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListSnoozePanel.invalidSnooze',
{
defaultMessage: 'Invalid',
}
);
export const INVALID_SNOOZE_TOOLTIP_CONTENT = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListSnoozePanel.invalidSnoozeTooltipContent',
{
defaultMessage: 'Click to remove',
}
);
export const INVALID_SNOOZE_TOOLTIP_TITLE = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListSnoozePanel.invalidSnoozeTooltipTitle',
{
defaultMessage: 'Invalid snooze settings',
}
);
export const OPEN_SNOOZE_PANEL_ARIA_LABEL = (name: string) =>
i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.openSnoozePanel',

View file

@ -72,7 +72,8 @@
"@kbn/core-ui-settings-browser",
"@kbn/observability-alerting-rule-utils",
"@kbn/core-application-browser",
"@kbn/cloud-plugin"
"@kbn/cloud-plugin",
"@kbn/rrule"
],
"exclude": ["target/**/*"]
}

View file

@ -214,5 +214,51 @@ export default function createMaintenanceWindowTests({ getService }: FtrProvider
})
.expect(400);
});
describe('validation', () => {
it('should return 400 if the timezone is not valid', async () => {
await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send({
...createParams,
r_rule: { ...createParams.r_rule, tzid: 'invalid' },
})
.expect(400);
});
it('should return 400 if the byweekday is not valid', async () => {
await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send({
...createParams,
r_rule: { ...createParams.r_rule, byweekday: ['invalid'] },
})
.expect(400);
});
it('should return 400 if the bymonthday is not valid', async () => {
await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send({
...createParams,
r_rule: { ...createParams.r_rule, bymonthday: [35] },
})
.expect(400);
});
it('should return 400 if the bymonth is not valid', async () => {
await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send({
...createParams,
r_rule: { ...createParams.r_rule, bymonth: [14] },
})
.expect(400);
});
});
});
}

View file

@ -305,5 +305,119 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
})
.expect(400);
});
describe('validation', () => {
it('should return 400 if the timezone is not valid', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams)
.expect(200);
objectRemover.add(
'space1',
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
await supertest
.post(
`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.set('kbn-xsrf', 'foo')
.send({
r_rule: { ...createParams.r_rule, tzid: 'invalid' },
})
.expect(400);
});
it('should return 400 if the byweekday is not valid', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams)
.expect(200);
objectRemover.add(
'space1',
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
await supertest
.post(
`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.set('kbn-xsrf', 'foo')
.send({
r_rule: { ...createParams.r_rule, byweekday: ['invalid'] },
})
.expect(400);
});
it('should return 400 if the bymonthday is not valid', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams)
.expect(200);
objectRemover.add(
'space1',
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
await supertest
.post(
`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.set('kbn-xsrf', 'foo')
.send({
r_rule: { ...createParams.r_rule, bymonthday: [35] },
})
.expect(400);
});
it('should return 400 if the bymonth is not valid', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams)
.expect(200);
objectRemover.add(
'space1',
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
await supertest
.post(
`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.set('kbn-xsrf', 'foo')
.send({
r_rule: { ...createParams.r_rule, bymonth: [14] },
})
.expect(400);
});
});
});
}

View file

@ -478,6 +478,38 @@ export default function createAlertTests({ getService }: FtrProviderContext) {
});
});
it('should return 400 if the timezone of an action is not valid', async () => {
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
actions: [
{
id: 'test-id',
group: 'default',
params: {},
alerts_filter: {
timeframe: {
days: [1, 2, 3, 4, 5, 6, 7],
timezone: 'invalid',
hours: { start: '00:00', end: '01:00' },
},
},
},
],
})
);
expect(response.status).to.eql(400);
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'[request body.actions.0.alerts_filter.timeframe.timezone]: string is not a valid timezone: invalid',
});
});
describe('system actions', () => {
const systemAction = {
id: 'system-connector-test.system-action',

View file

@ -182,6 +182,51 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
});
});
it('should return 400 if the timezone of an action is not valid', async () => {
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData())
.expect(200);
objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting');
const updatedData = {
name: 'bcd',
tags: ['bar'],
schedule: { interval: '12s' },
throttle: '1m',
params: {},
actions: [
{
id: 'test-id',
group: 'default',
params: {},
alerts_filter: {
timeframe: {
days: [1, 2, 3, 4, 5, 6, 7],
timezone: 'invalid',
hours: { start: '00:00', end: '01:00' },
},
},
},
],
};
const response = await supertest
.put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${createdAlert.id}`)
.set('kbn-xsrf', 'foo')
.send(updatedData);
expect(response.status).to.eql(400);
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'[request body.actions.0.alerts_filter.timeframe.timezone]: string is not a valid timezone: invalid',
});
});
describe('update rule flapping', () => {
afterEach(async () => {
await resetRulesSettings(supertest, 'space1');

View file

@ -407,6 +407,159 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext
});
});
});
describe('validation', () => {
it('should return 400 if the id is not in a valid format', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const response = await alertUtils.getSnoozeRequest(createdRule.id).send({
snooze_schedule: {
...SNOOZE_SCHEDULE,
id: 'invalid key',
},
});
expect(response.statusCode).to.eql(400);
expect(response.body.message).to.eql(
`[request body.snooze_schedule.id]: Key must be lower case, a-z, 0-9, '_', and '-' are allowed`
);
});
it('accepts a uuid as a key', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
await alertUtils
.getSnoozeRequest(createdRule.id)
.send({
snooze_schedule: {
...SNOOZE_SCHEDULE,
id: 'e58e2340-dba6-454c-8308-b2ca66a7cf7',
},
})
.expect(204);
});
it('should return 400 if the timezone is not valid', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const response = await alertUtils.getSnoozeRequest(createdRule.id).send({
snooze_schedule: {
...SNOOZE_SCHEDULE,
rRule: { ...SNOOZE_SCHEDULE.rRule, tzid: 'invalid' },
},
});
expect(response.statusCode).to.eql(400);
expect(response.body.message).to.eql(
'[request body.snooze_schedule.rRule.tzid]: string is not a valid timezone: invalid'
);
});
it('should return 400 if the byweekday is not valid', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const response = await alertUtils.getSnoozeRequest(createdRule.id).send({
snooze_schedule: {
...SNOOZE_SCHEDULE,
rRule: { ...SNOOZE_SCHEDULE.rRule, byweekday: ['invalid'] },
},
});
expect(response.statusCode).to.eql(400);
});
it('should return 400 if the bymonthday is not valid', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const response = await alertUtils.getSnoozeRequest(createdRule.id).send({
snooze_schedule: {
...SNOOZE_SCHEDULE,
rRule: { ...SNOOZE_SCHEDULE.rRule, bymonthday: [35] },
},
});
expect(response.statusCode).to.eql(400);
expect(response.body.message).to.eql(
'[request body.snooze_schedule.rRule.bymonthday.0]: Value must be equal to or lower than [31].'
);
});
it('should return 400 if the bymonth is not valid', async () => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
enabled: false,
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting');
const response = await alertUtils.getSnoozeRequest(createdRule.id).send({
snooze_schedule: {
...SNOOZE_SCHEDULE,
rRule: { ...SNOOZE_SCHEDULE.rRule, bymonth: [14] },
},
});
expect(response.statusCode).to.eql(400);
expect(response.body.message).to.eql(
'[request body.snooze_schedule.rRule.bymonth.0]: Value must be equal to or lower than [12].'
);
});
});
});
async function getRuleEvents(id: string, minActions: number = 1) {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import moment from 'moment';
import moment from 'moment-timezone';
import {
DETECTION_ENGINE_RULES_BULK_ACTION,
DETECTION_ENGINE_RULES_URL,
@ -69,7 +69,7 @@ export const snoozeRule = (id: string, duration: number): Cypress.Chainable =>
body: {
snooze_schedule: {
duration,
rRule: { dtstart: new Date().toISOString(), count: 1, tzid: moment().format('zz') },
rRule: { dtstart: new Date().toISOString(), count: 1, tzid: moment.tz.guess() },
},
},
failOnStatusCode: false,