[ResponseOps] [Alerting] Handle invalid RRule params and prevent infinite looping (#205650)

## Summary

Closes https://github.com/elastic/kibana/issues/205558

Updates the RRule library to correctly handle some scenarios with
invalid parameters that would either cause it to return strange
recurrence data or to infinitely loop. Specifically:

- On `RRule` object creation, removes and ignores any `bymonth`,
`bymonthday`, `byweekday`, or `byyearday` value that's out of bounds,
e.g. less than 0 or greater than the number of possible months, days,
weekdays, etc.
- Successfully ignores cases of `BYMONTH=2, BYMONTHDAY=30` (February
30th), an input that's complicated to invalidate but still won't ever
occur

Allowing these values to go unhandled led to unpredictable behavior. The
RRule library uses Moment.js to compare dates, but Moment.js months,
days, and other values generally start at `0` while RRule values start
at `1`. That led to several circumstances where we passed Moment.js a
value of `-1`, which Moment.js interpreted as moving to the
***previous*** year, month, or other period of time.

At worst, this could cause an infinite loop because the RRule library
was constantly iterating through the wrong year, never reaching the date
it was supposed to end on.

In addition to making the RRule library more able to handle these cases,
this PR also gives it a hard 100,000 iteration limit to prevent any
possible infinite loops we've missed.

Lastly, the Snooze Schedule APIs also come with additional validation to
hopefully prevent out of bounds dates from ever being set.

### Checklist

- [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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com>
Co-authored-by: Janki Salvi <jankigaurav.salvi@elastic.co>
Co-authored-by: adcoelho <antonio.coelho@elastic.co>
This commit is contained in:
Zacqary Adam Xeper 2025-01-07 13:32:43 -06:00 committed by GitHub
parent ca42d93bd4
commit b30210929b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 615 additions and 68 deletions

View file

@ -7,6 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { RRule, Frequency, Weekday } from './rrule';
export type { Options } from './rrule';
export declare type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
export { RRule } from './rrule';
export type { Options, WeekdayStr } from './types';
export { Frequency, Weekday } from './types';

View file

@ -8,7 +8,8 @@
*/
import sinon from 'sinon';
import { RRule, Frequency, Weekday } from './rrule';
import { RRule } from './rrule';
import { Frequency, Weekday } from './types';
const DATE_2019 = '2019-01-01T00:00:00.000Z';
const DATE_2019_DECEMBER_19 = '2019-12-19T00:00:00.000Z';
@ -730,6 +731,228 @@ describe('RRule', () => {
]
`);
});
it('ignores invalid byweekday values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.WEEKLY,
interval: 1,
tzid: 'UTC',
byweekday: [Weekday.TH, 0, -2],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2019-12-19T00:00:00.000Z,
2019-12-26T00:00:00.000Z,
2020-01-02T00:00:00.000Z,
2020-01-09T00:00:00.000Z,
2020-01-16T00:00:00.000Z,
2020-01-23T00:00:00.000Z,
2020-01-30T00:00:00.000Z,
2020-02-06T00:00:00.000Z,
2020-02-13T00:00:00.000Z,
2020-02-20T00:00:00.000Z,
2020-02-27T00:00:00.000Z,
2020-03-05T00:00:00.000Z,
2020-03-12T00:00:00.000Z,
2020-03-19T00:00:00.000Z,
]
`);
const rule2 = new RRule({
dtstart: new Date(DATE_2019),
freq: Frequency.WEEKLY,
interval: 1,
tzid: 'UTC',
byweekday: [Weekday.SA, Weekday.SU, Weekday.MO, 0],
});
expect(rule2.all(9)).toMatchInlineSnapshot(`
Array [
2019-01-05T00:00:00.000Z,
2019-01-06T00:00:00.000Z,
2019-01-07T00:00:00.000Z,
2019-01-12T00:00:00.000Z,
2019-01-13T00:00:00.000Z,
2019-01-14T00:00:00.000Z,
2019-01-19T00:00:00.000Z,
2019-01-20T00:00:00.000Z,
2019-01-21T00:00:00.000Z,
]
`);
});
});
describe('bymonth', () => {
it('works with yearly frequency', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [2, 5],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-02-19T00:00:00.000Z,
2020-05-19T00:00:00.000Z,
2021-02-19T00:00:00.000Z,
2021-05-19T00:00:00.000Z,
2022-02-19T00:00:00.000Z,
2022-05-19T00:00:00.000Z,
2023-02-19T00:00:00.000Z,
2023-05-19T00:00:00.000Z,
2024-02-19T00:00:00.000Z,
2024-05-19T00:00:00.000Z,
2025-02-19T00:00:00.000Z,
2025-05-19T00:00:00.000Z,
2026-02-19T00:00:00.000Z,
2026-05-19T00:00:00.000Z,
]
`);
});
it('ignores invalid bymonth values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [0],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2019-12-19T00:00:00.000Z,
2020-12-19T00:00:00.000Z,
2021-12-19T00:00:00.000Z,
2022-12-19T00:00:00.000Z,
2023-12-19T00:00:00.000Z,
2024-12-19T00:00:00.000Z,
2025-12-19T00:00:00.000Z,
2026-12-19T00:00:00.000Z,
2027-12-19T00:00:00.000Z,
2028-12-19T00:00:00.000Z,
2029-12-19T00:00:00.000Z,
2030-12-19T00:00:00.000Z,
2031-12-19T00:00:00.000Z,
2032-12-19T00:00:00.000Z,
]
`);
});
});
describe('bymonthday', () => {
it('works with monthly frequency', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.MONTHLY,
interval: 1,
tzid: 'UTC',
bymonthday: [1, 15],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-01-01T00:00:00.000Z,
2020-01-15T00:00:00.000Z,
2020-02-01T00:00:00.000Z,
2020-02-15T00:00:00.000Z,
2020-03-01T00:00:00.000Z,
2020-03-15T00:00:00.000Z,
2020-04-01T00:00:00.000Z,
2020-04-15T00:00:00.000Z,
2020-05-01T00:00:00.000Z,
2020-05-15T00:00:00.000Z,
2020-06-01T00:00:00.000Z,
2020-06-15T00:00:00.000Z,
2020-07-01T00:00:00.000Z,
2020-07-15T00:00:00.000Z,
]
`);
});
it('ignores invalid bymonthday values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.MONTHLY,
interval: 1,
tzid: 'UTC',
bymonthday: [0, -1, 32],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2019-12-19T00:00:00.000Z,
2020-01-19T00:00:00.000Z,
2020-02-19T00:00:00.000Z,
2020-03-19T00:00:00.000Z,
2020-04-19T00:00:00.000Z,
2020-05-19T00:00:00.000Z,
2020-06-19T00:00:00.000Z,
2020-07-19T00:00:00.000Z,
2020-08-19T00:00:00.000Z,
2020-09-19T00:00:00.000Z,
2020-10-19T00:00:00.000Z,
2020-11-19T00:00:00.000Z,
2020-12-19T00:00:00.000Z,
2021-01-19T00:00:00.000Z,
]
`);
});
});
describe('bymonth, bymonthday', () => {
it('works with yearly frequency', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [2, 5],
bymonthday: [8],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-02-08T00:00:00.000Z,
2020-05-08T00:00:00.000Z,
2021-02-08T00:00:00.000Z,
2021-05-08T00:00:00.000Z,
2022-02-08T00:00:00.000Z,
2022-05-08T00:00:00.000Z,
2023-02-08T00:00:00.000Z,
2023-05-08T00:00:00.000Z,
2024-02-08T00:00:00.000Z,
2024-05-08T00:00:00.000Z,
2025-02-08T00:00:00.000Z,
2025-05-08T00:00:00.000Z,
2026-02-08T00:00:00.000Z,
2026-05-08T00:00:00.000Z,
]
`);
});
it('ignores valid dates that do not exist e.g. February 30th', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [2, 5],
bymonthday: [30],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-05-30T00:00:00.000Z,
2021-05-30T00:00:00.000Z,
2022-05-30T00:00:00.000Z,
2023-05-30T00:00:00.000Z,
2024-05-30T00:00:00.000Z,
2025-05-30T00:00:00.000Z,
2026-05-30T00:00:00.000Z,
2027-05-30T00:00:00.000Z,
2028-05-30T00:00:00.000Z,
2029-05-30T00:00:00.000Z,
2030-05-30T00:00:00.000Z,
2031-05-30T00:00:00.000Z,
2032-05-30T00:00:00.000Z,
2033-05-30T00:00:00.000Z,
]
`);
});
});
describe('byhour, byminute, bysecond', () => {
@ -844,6 +1067,30 @@ describe('RRule', () => {
]
`);
});
it('ignores invalid byyearday values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2020),
freq: Frequency.YEARLY,
byyearday: [0, -1],
interval: 1,
tzid: 'UTC',
});
expect(rule.all(10)).toMatchInlineSnapshot(`
Array [
2020-01-01T00:00:00.000Z,
2021-01-01T00:00:00.000Z,
2022-01-01T00:00:00.000Z,
2023-01-01T00:00:00.000Z,
2024-01-01T00:00:00.000Z,
2025-01-01T00:00:00.000Z,
2026-01-01T00:00:00.000Z,
2027-01-01T00:00:00.000Z,
2028-01-01T00:00:00.000Z,
2029-01-01T00:00:00.000Z,
]
`);
});
});
describe('error handling', () => {
@ -872,5 +1119,33 @@ describe('RRule', () => {
`"Cannot create RRule: until is an invalid date"`
);
});
it('throws an error on an interval of 0', () => {
const testFn = () =>
new RRule({
dtstart: new Date(DATE_2020),
freq: Frequency.HOURLY,
interval: 0,
tzid: 'UTC',
});
expect(testFn).toThrowErrorMatchingInlineSnapshot(
`"Cannot create RRule: interval must be greater than 0"`
);
});
it('throws an error when exceeding the iteration limit', () => {
const testFn = () => {
const rule = new RRule({
dtstart: new Date(DATE_2020),
freq: Frequency.YEARLY,
byyearday: [1],
interval: 1,
tzid: 'UTC',
});
rule.all(100001);
};
expect(testFn).toThrowErrorMatchingInlineSnapshot(`"RRule iteration limit exceeded"`);
});
});
});

View file

@ -7,58 +7,16 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import moment, { Moment } from 'moment-timezone';
import moment, { type Moment } from 'moment-timezone';
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
SECONDLY = 6,
}
export enum Weekday {
MO = 1,
TU = 2,
WE = 3,
TH = 4,
FR = 5,
SA = 6,
SU = 7,
}
export type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
interface IterOptions {
refDT: Moment;
wkst?: Weekday | number | null;
byyearday?: number[] | null;
bymonth?: number[] | null;
bysetpos?: number[] | null;
bymonthday?: number[] | null;
byweekday?: Weekday[] | null;
byhour?: number[] | null;
byminute?: number[] | null;
bysecond?: number[] | null;
}
type Options = Omit<IterOptions, 'refDT'> & {
dtstart: Date;
freq?: Frequency;
interval?: number;
until?: Date | null;
count?: number;
tzid: string;
};
import { Frequency, Weekday, type WeekdayStr, type Options, type IterOptions } from './types';
import { sanitizeOptions } from './sanitize';
type ConstructorOptions = Omit<Options, 'byweekday' | 'wkst'> & {
byweekday?: Array<string | number> | null;
wkst?: Weekday | WeekdayStr | number | null;
};
export type { ConstructorOptions as Options };
const ISO_WEEKDAYS = [
Weekday.MO,
Weekday.TU,
@ -74,19 +32,15 @@ type AllResult = Date[] & {
};
const ALL_LIMIT = 10000;
const TIMEOUT_LIMIT = 100000;
export class RRule {
private options: Options;
constructor(options: ConstructorOptions) {
this.options = options as Options;
if (isNaN(options.dtstart.getTime())) {
throw new Error('Cannot create RRule: dtstart is an invalid date');
}
if (options.until && isNaN(options.until.getTime())) {
throw new Error('Cannot create RRule: until is an invalid date');
}
this.options = sanitizeOptions(options as Options);
if (typeof options.wkst === 'string') {
this.options.wkst = Weekday[options.wkst];
if (!this.options.wkst) delete this.options.wkst;
}
const weekdayParseResult = parseByWeekdayPos(options.byweekday);
if (weekdayParseResult) {
@ -112,12 +66,17 @@ export class RRule {
.toDate();
const nextRecurrences: Moment[] = [];
let iters = 0;
while (
(!count && !until) ||
(count && yieldedRecurrenceCount < count) ||
(until && current.getTime() < new Date(until).getTime())
(until && current.getTime() < until.getTime())
) {
iters++;
if (iters > TIMEOUT_LIMIT) {
throw new Error('RRule iteration limit exceeded');
}
const next = nextRecurrences.shift()?.toDate();
if (next) {
current = next;
@ -313,6 +272,7 @@ const getYearOfRecurrences = function ({
return getMonthOfRecurrences({
refDT: currentMonth,
wkst,
bymonth,
bymonthday,
byweekday,
byhour,
@ -327,6 +287,7 @@ const getYearOfRecurrences = function ({
return derivedByyearday.flatMap((dayOfYear) => {
const currentDate = moment(refDT).dayOfYear(dayOfYear);
if (currentDate.year() !== refDT.year()) return [];
if (!derivedByweekday.includes(currentDate.isoWeekday())) return [];
return getDayOfRecurrences({ refDT: currentDate, byhour, byminute, bysecond });
});
@ -345,7 +306,7 @@ const getMonthOfRecurrences = function ({
}: IterOptions) {
const derivedByweekday = byweekday ?? ISO_WEEKDAYS;
const currentMonth = refDT.month();
if (bymonth && !bymonth.includes(currentMonth)) return [];
if (bymonth && !bymonth.includes(currentMonth + 1)) return [];
let derivedBymonthday = bymonthday ?? [refDT.date()];
if (bysetpos) {
@ -392,6 +353,7 @@ const getMonthOfRecurrences = function ({
return derivedBymonthday.flatMap((date) => {
const currentDate = moment(refDT).date(date);
if (bymonth && !bymonth.includes(currentDate.month() + 1)) return [];
if (!derivedByweekday.includes(currentDate.isoWeekday())) return [];
return getDayOfRecurrences({ refDT: currentDate, byhour, byminute, bysecond });
});

View file

@ -0,0 +1,131 @@
/*
* 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 { sanitizeOptions } from './sanitize';
import { Weekday, Frequency, type Options } from './types';
describe('sanitizeOptions', () => {
const options: Options = {
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: 'foobar',
};
it('happy path', () => {
expect(sanitizeOptions(options)).toEqual(options);
});
it('throws an error when dtstart is missing', () => {
// @ts-expect-error
expect(() => sanitizeOptions({ ...options, dtstart: null })).toThrowError(
'Cannot create RRule: dtstart is required'
);
});
it('throws an error when tzid is missing', () => {
expect(() => sanitizeOptions({ ...options, tzid: '' })).toThrowError(
'Cannot create RRule: tzid is required'
);
});
it('throws an error when until field is invalid', () => {
expect(() =>
sanitizeOptions({
...options,
// @ts-expect-error
until: {
getTime: () => NaN,
},
})
).toThrowError('Cannot create RRule: until is an invalid date');
});
it('throws an error when interval is less than 0', () => {
expect(() => sanitizeOptions({ ...options, interval: -3 })).toThrowError(
'Cannot create RRule: interval must be greater than 0'
);
});
it('throws an error when interval is not a number', () => {
// @ts-expect-error
expect(() => sanitizeOptions({ ...options, interval: 'foobar' })).toThrowError(
'Cannot create RRule: interval must be a number'
);
});
it('filters out invalid bymonth values', () => {
expect(sanitizeOptions({ ...options, bymonth: [0, 6, 13] })).toEqual({
...options,
bymonth: [6],
});
});
it('removes bymonth when it is empty', () => {
expect(sanitizeOptions({ ...options, bymonth: [0] })).toEqual({
...options,
bymonth: undefined,
});
});
it('filters out invalid bymonthday values', () => {
expect(sanitizeOptions({ ...options, bymonthday: [0, 15, 32] })).toEqual({
...options,
bymonthday: [15],
});
});
it('removes bymonthday when it is empty', () => {
expect(sanitizeOptions({ ...options, bymonthday: [0] })).toEqual({
...options,
bymonthday: undefined,
});
});
it('filters out invalid byweekday values', () => {
// @ts-expect-error
expect(sanitizeOptions({ ...options, byweekday: [0, 4, 8] })).toEqual({
...options,
byweekday: [4],
});
});
it('removes byweekday when it is empty', () => {
// @ts-expect-error
expect(sanitizeOptions({ ...options, byweekday: [0] })).toEqual({
...options,
byweekday: undefined,
});
});
it('filters out invalid byyearday values', () => {
expect(sanitizeOptions({ ...options, byyearday: [0, 150, 367] })).toEqual({
...options,
byyearday: [150],
});
});
it('removes byyearday when it is empty', () => {
expect(sanitizeOptions({ ...options, byyearday: [0] })).toEqual({
...options,
byyearday: undefined,
});
});
});

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", 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 type { Options } from './types';
export function sanitizeOptions(opts: Options) {
const options = { ...opts };
// Guard against invalid options that can't be omitted
if (!options.dtstart) {
throw new Error('Cannot create RRule: dtstart is required');
}
if (!options.tzid) {
throw new Error('Cannot create RRule: tzid is required');
}
if (isNaN(options.dtstart.getTime())) {
throw new Error('Cannot create RRule: dtstart is an invalid date');
}
if (options.until && isNaN(options.until.getTime())) {
throw new Error('Cannot create RRule: until is an invalid date');
}
if (options.interval != null) {
if (typeof options.interval !== 'number') {
throw new Error('Cannot create RRule: interval must be a number');
}
if (options.interval < 1) {
throw new Error('Cannot create RRule: interval must be greater than 0');
}
}
// Omit invalid options
if (options.bymonth) {
// Only months between 1 and 12 are valid
options.bymonth = options.bymonth.filter(
(month) => typeof month === 'number' && month >= 1 && month <= 12
);
if (!options.bymonth.length) {
delete options.bymonth;
}
}
if (options.bymonthday) {
// Only days between 1 and 31 are valid
options.bymonthday = options.bymonthday.filter(
(day) => typeof day === 'number' && day >= 1 && day <= 31
);
if (!options.bymonthday.length) {
delete options.bymonthday;
}
}
if (options.byweekday) {
// Only weekdays between 1 and 7 are valid
options.byweekday = options.byweekday.filter(
(weekday) => typeof weekday === 'number' && weekday >= 1 && weekday <= 7
);
if (!options.byweekday.length) {
delete options.byweekday;
}
}
if (options.byyearday) {
// Only days between 1 and 366 are valid
options.byyearday = options.byyearday.filter((day) => day >= 1 && day <= 366);
if (!options.byyearday.length) {
delete options.byyearday;
}
}
return options;
}

View file

@ -0,0 +1,54 @@
/*
* 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 type { Moment } from 'moment';
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
SECONDLY = 6,
}
export enum Weekday {
MO = 1,
TU = 2,
WE = 3,
TH = 4,
FR = 5,
SA = 6,
SU = 7,
}
export type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
export interface IterOptions {
refDT: Moment;
wkst?: Weekday | number | null;
byyearday?: number[] | null;
bymonth?: number[] | null;
bysetpos?: number[] | null;
bymonthday?: number[] | null;
byweekday?: Weekday[] | null;
byhour?: number[] | null;
byminute?: number[] | null;
bysecond?: number[] | null;
}
export type Options = Omit<IterOptions, 'refDT'> & {
dtstart: Date;
freq?: Frequency;
interval?: number;
until?: Date | null;
count?: number;
tzid: string;
};

View file

@ -40,17 +40,28 @@ export const rRuleRequestSchema = schema.object({
})
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
validate: createValidateRecurrenceByV1('byweekday'),
})
schema.arrayOf(
schema.oneOf([
schema.literal('MO'),
schema.literal('TU'),
schema.literal('WE'),
schema.literal('TH'),
schema.literal('FR'),
schema.literal('SA'),
schema.literal('SU'),
]),
{
validate: createValidateRecurrenceByV1('byweekday'),
}
)
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 31 }), {
validate: createValidateRecurrenceByV1('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 12 }), {
validate: createValidateRecurrenceByV1('bymonth'),
})
),

View file

@ -35,17 +35,28 @@ export const rRuleRequestSchema = schema.object({
})
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
validate: createValidateRecurrenceBy('byweekday'),
})
schema.arrayOf(
schema.oneOf([
schema.literal('MO'),
schema.literal('TU'),
schema.literal('WE'),
schema.literal('TH'),
schema.literal('FR'),
schema.literal('SA'),
schema.literal('SU'),
]),
{
validate: createValidateRecurrenceBy('byweekday'),
}
)
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 31 }), {
validate: createValidateRecurrenceBy('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 12 }), {
validate: createValidateRecurrenceBy('bymonth'),
})
),

View file

@ -230,4 +230,25 @@ describe('isSnoozeActive', () => {
expect(isSnoozeActive(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(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
});