Reject cron steps that would silently run hourly (#8607)

* fix(schedules): reject ignored numeric cron steps

* fix(schedules): preserve saved numeric steps during recovery

Reject unsupported numeric steps at create/update while keeping saved
schedules executable with their previous singleton cadence.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-03 00:56:28 +00:00
committed by GitHub
co-authored by 见安
parent 07a2e4accd
commit 736f7bf042
4 changed files with 123 additions and 2 deletions
@@ -13,6 +13,37 @@ describe("cron expression parser", () => {
expect(cron.dayOfWeek.matches(0)).toBe(false);
});
it("rejects a step after a standalone number", () => {
expect(validateCronExpression("0/15 * * * *")).toBe(
"Invalid cron minute step: use * or a range before /",
);
});
it.each([
["5/1 * * * *", "minute"],
["0,5/15 * * * *", "minute"],
["* 1/2 * * *", "hour"],
["* * 1/2 * *", "day-of-month"],
["* * * 1/2 *", "month"],
["* * * * 1/2", "day-of-week"],
])("rejects ignored numeric steps in %s", (expression, field) => {
expect(validateCronExpression(expression)).toBe(
`Invalid cron ${field} step: use * or a range before /`,
);
});
it.each([
{ expression: "*/15 * * * *", expected: [0, 15, 30, 45] },
{ expression: "0-59/15 * * * *", expected: [0, 15, 30, 45] },
{ expression: "5-59/15 * * * *", expected: [5, 20, 35, 50] },
])("preserves supported steps in $expression", ({ expression, expected }) => {
const cron = parseCronExpression(expression);
const minutes = Array.from({ length: 60 }, (_, minute) => minute);
const matches = minutes.filter((minute) => cron.minute.matches(minute));
expect(matches).toEqual(expected);
});
it("reports invalid expressions with server-facing copy", () => {
expect(validateCronExpression("* * *")).toBe("Cron expressions must have 5 fields");
expect(validateCronExpression("*/5/2 * * * *")).toBe("Invalid cron minute step");
@@ -90,6 +90,9 @@ function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher {
if (!/^\d+$/.test(base)) {
throw new Error(`Invalid cron ${bounds.name} value`);
}
// COMPAT(cronNumericSteps): added in v0.12.0, remove after 2027-04-17 once
// persisted numeric steps are migrated. Validation rejects new numeric steps;
// existing schedules retain their historical singleton match.
const value = Number.parseInt(base, 10);
if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
throw new Error(`Invalid cron ${bounds.name} value`);
@@ -122,6 +125,12 @@ export function parseCronExpression(expression: string): ParsedCronExpression {
export function validateCronExpression(expression: string): string | null {
try {
parseCronExpression(expression);
const fields = expression.trim().split(/\s+/);
for (const [index, field] of fields.entries()) {
if (field.split(",").some((part) => /^\d+\//.test(part))) {
return `Invalid cron ${CRON_FIELD_BOUNDS[index].name} step: use * or a range before /`;
}
}
return null;
} catch (error) {
return error instanceof Error ? error.message : "Invalid cron expression";