157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|||
|
|
import {
|
||
|
|
atTimestampIsPast,
|
||
|
|
describeCron,
|
||
|
|
validateAtTimestamp,
|
||
|
|
validateCronExpression,
|
||
|
|
validateTaskName,
|
||
|
|
validateTaskPrompt,
|
||
|
|
validateWorkingDir,
|
||
|
|
MAX_TASK_NAME_LEN,
|
||
|
|
MAX_TASK_PROMPT_LEN,
|
||
|
|
} from "./taskValidation";
|
||
|
|
|
||
|
|
describe("task field validation", () => {
|
||
|
|
it("requires a name that cannot be read as an option", () => {
|
||
|
|
expect(validateTaskName("nightly")).toBeNull();
|
||
|
|
expect(validateTaskName(" nightly ")).toBeNull();
|
||
|
|
expect(validateTaskName("")).toMatch(/required/i);
|
||
|
|
expect(validateTaskName(" ")).toMatch(/required/i);
|
||
|
|
expect(validateTaskName("-id")).toMatch(/cannot start/i);
|
||
|
|
expect(validateTaskName("--prompt")).toMatch(/cannot start/i);
|
||
|
|
expect(validateTaskName("two\nlines")).toMatch(/single line/i);
|
||
|
|
expect(validateTaskName("n".repeat(MAX_TASK_NAME_LEN + 1))).toMatch(/too long/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("treats shell syntax in a name or prompt as ordinary text", () => {
|
||
|
|
// Nothing downstream is a shell, so these must not be rejected —
|
||
|
|
// over-blocking would be its own bug.
|
||
|
|
for (const value of ["; rm -rf /", "$(id)", "`id`", "a | b && c", "%pct"]) {
|
||
|
|
expect(validateTaskName(value)).toBeNull();
|
||
|
|
expect(validateTaskPrompt(value)).toBeNull();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("requires a prompt and allows it to be multi-line", () => {
|
||
|
|
expect(validateTaskPrompt("Run the tests\nthen report")).toBeNull();
|
||
|
|
expect(validateTaskPrompt("")).toMatch(/required/i);
|
||
|
|
expect(validateTaskPrompt(" \n ")).toMatch(/required/i);
|
||
|
|
expect(validateTaskPrompt("p".repeat(MAX_TASK_PROMPT_LEN + 1))).toMatch(/too long/i);
|
||
|
|
expect(validateTaskPrompt("bad\u0000nul")).toMatch(/unsupported/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("requires an absolute working directory, defaulting when blank", () => {
|
||
|
|
expect(validateWorkingDir("")).toBeNull();
|
||
|
|
expect(validateWorkingDir("/workspace/app")).toBeNull();
|
||
|
|
expect(validateWorkingDir("workspace")).toMatch(/absolute/i);
|
||
|
|
expect(validateWorkingDir("./rel")).toMatch(/absolute/i);
|
||
|
|
expect(validateWorkingDir("~/home")).toMatch(/absolute/i);
|
||
|
|
expect(validateWorkingDir("/workspace/../etc")).toMatch(/\.\./);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("cron validation", () => {
|
||
|
|
// Every expression below was checked against the container's own
|
||
|
|
// Debian/vixie `crontab` binary, which is the thing that ultimately accepts
|
||
|
|
// or rejects the schedule.
|
||
|
|
it("accepts expressions vixie cron accepts", () => {
|
||
|
|
for (const good of [
|
||
|
|
"* * * * *",
|
||
|
|
"*/30 * * * *",
|
||
|
|
"0 3 * * *",
|
||
|
|
"0 9 * * 1-5",
|
||
|
|
"0,30 9-17 * * 1-5",
|
||
|
|
"15 0 1 1 *",
|
||
|
|
"0 9 * * 0",
|
||
|
|
"0 9 * * 7",
|
||
|
|
"0 9 * * MON-FRI",
|
||
|
|
"0 0 1 JAN *",
|
||
|
|
"0-59/70 * * * *",
|
||
|
|
"1-5/2 * * * *",
|
||
|
|
"05 09 * * *",
|
||
|
|
]) {
|
||
|
|
expect(validateCronExpression(good), good).toBeNull();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("rejects expressions vixie cron rejects", () => {
|
||
|
|
for (const bad of [
|
||
|
|
"",
|
||
|
|
"* * * *",
|
||
|
|
"* * * * * *",
|
||
|
|
"@daily",
|
||
|
|
"not a cron",
|
||
|
|
"99 * * * *",
|
||
|
|
"0 24 * * *",
|
||
|
|
"0 0 0 1 *",
|
||
|
|
"0 9 * * 8",
|
||
|
|
"0 9 * 13 *",
|
||
|
|
"*/0 * * * *",
|
||
|
|
"1/2 * * * *",
|
||
|
|
"0 9 * * jan",
|
||
|
|
"jan 9 * * *",
|
||
|
|
"0 9 * * mon,",
|
||
|
|
"0 9 * * 1--5",
|
||
|
|
"0 9 * * 1-5/x",
|
||
|
|
"0 9 * * *; rm -rf /",
|
||
|
|
"$(id) * * * *",
|
||
|
|
]) {
|
||
|
|
expect(validateCronExpression(bad), bad).not.toBeNull();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("matches the backend's message shape for the field count", () => {
|
||
|
|
expect(validateCronExpression("* * * *")).toMatch(/exactly 5 fields/);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("describeCron", () => {
|
||
|
|
const cases: [string, string][] = [
|
||
|
|
["* * * * *", "Every minute, every day."],
|
||
|
|
["*/30 * * * *", "Every 30 minutes, every day."],
|
||
|
|
["0 * * * *", "At :00 past every hour, every day."],
|
||
|
|
["0,30 * * * *", "At :00 and :30 past every hour, every day."],
|
||
|
|
["0 9 * * *", "At 09:00, every day."],
|
||
|
|
["30 9 * * 1-5", "At 09:30, on Monday to Friday."],
|
||
|
|
["0 8 * * 1", "At 08:00, on Monday."],
|
||
|
|
["0 9 * * 0", "At 09:00, on Sunday."],
|
||
|
|
// 7 is Sunday too, and must not read as an eighth day.
|
||
|
|
["0 9 * * 7", "At 09:00, on Sunday."],
|
||
|
|
["0 9,17 * * *", "At 09:00 and 17:00, every day."],
|
||
|
|
["0 9-17 * * *", "At :00 past every hour from 09:00 to 17:00, every day."],
|
||
|
|
["0 */2 * * *", "At :00 past every 2 hours, every day."],
|
||
|
|
["0 0 1 * *", "At 00:00, on day 1 of the month."],
|
||
|
|
["0 0 1 1 *", "At 00:00, on day 1 of the month in January."],
|
||
|
|
["0 9 * * MON,THU", "At 09:00, on Monday and Thursday."],
|
||
|
|
];
|
||
|
|
|
||
|
|
it.each(cases)("reads %s as %s", (expression, expected) => {
|
||
|
|
expect(describeCron(expression)).toBe(expected);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("says nothing rather than guessing when the expression is invalid", () => {
|
||
|
|
expect(describeCron("nope")).toBeNull();
|
||
|
|
expect(describeCron("99 * * * *")).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("one-shot timestamps", () => {
|
||
|
|
it("accepts only the scheduler's own format", () => {
|
||
|
|
expect(validateAtTimestamp("2026-12-25 09:05")).toBeNull();
|
||
|
|
expect(validateAtTimestamp("")).toMatch(/required/i);
|
||
|
|
// The scheduler's regex demands two digits everywhere.
|
||
|
|
expect(validateAtTimestamp("2026-1-5 09:05")).toMatch(/YYYY-MM-DD/);
|
||
|
|
expect(validateAtTimestamp("2026-12-25T09:05")).toMatch(/YYYY-MM-DD/);
|
||
|
|
expect(validateAtTimestamp("2026-12-25 09:05:00")).toMatch(/YYYY-MM-DD/);
|
||
|
|
expect(validateAtTimestamp("2026-02-30 09:05")).toMatch(/not a real/i);
|
||
|
|
expect(validateAtTimestamp("2026-12-25 25:00")).toMatch(/not a real/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("flags a time in the past, because cron would fire it next year", () => {
|
||
|
|
const now = new Date(2026, 5, 1, 12, 0);
|
||
|
|
expect(atTimestampIsPast("2026-05-31 09:00", now)).toBe(true);
|
||
|
|
expect(atTimestampIsPast("2026-06-01 12:01", now)).toBe(false);
|
||
|
|
expect(atTimestampIsPast("nonsense", now)).toBe(false);
|
||
|
|
});
|
||
|
|
});
|