Files
Triple-C/app/src/components/projects/home/taskValidation.test.ts
T

157 lines
5.7 KiB
TypeScript
Raw Normal View History

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);
});
});