91 lines · 3.9 KB · TypeScript
RawDownload
import { mkdtempSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest";import { Effect } from "effect"; import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing";import { cronMatches, makeScheduler, validateCron, CronError } from "./scheduler"; const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); describe("scheduler", () => {  it("matches cron fields (minute hour dom mon dow)", () => {    // "0 9 * * 1-5" -> weekdays at 09:00 UTC.    const weekdayNine = new Date(Date.UTC(2026, 0, 5, 9, 0, 0)); // Mon 2026-01-05    expect(cronMatches("0 9 * * 1-5", weekdayNine)).toBe(true);    const weekendNine = new Date(Date.UTC(2026, 0, 4, 9, 0, 0)); // Sun    expect(cronMatches("0 9 * * 1-5", weekendNine)).toBe(false);    const weekdayTen = new Date(Date.UTC(2026, 0, 5, 10, 0, 0));    expect(cronMatches("0 9 * * 1-5", weekdayTen)).toBe(false);    expect(cronMatches("*/15 * * * *", new Date(Date.UTC(2026, 0, 5, 3, 30, 0)))).toBe(true);    expect(cronMatches("*/15 * * * *", new Date(Date.UTC(2026, 0, 5, 3, 31, 0)))).toBe(false);  });   // --- Fix 8: adversarial cron strings never hang the parser ---------------  it("rejects adversarial cron fields via validateCron (never loops)", () => {    // A step of 0 would loop forever in the naive parser.    expect(() => validateCron("*/0 * * * *")).toThrow(CronError);    expect(() => validateCron("*/-1 * * * *")).toThrow(CronError);    // Out-of-range and reversed ranges are bounded, not billion-iteration loops.    expect(() => validateCron("0 0 1 1 0-999")).toThrow(CronError);    expect(() => validateCron("0 0 999999999-0 * *")).toThrow(CronError);    expect(() => validateCron("nonsense")).toThrow(CronError);    expect(() => validateCron("* * * *")).toThrow(CronError); // 4 fields    // A valid cron passes.    expect(() => validateCron("0 9 * * 1-5")).not.toThrow();    expect(() => validateCron("*/15 * * * *")).not.toThrow();  });   it("cronMatches returns quickly (no hang) on adversarial strings", () => {    const date = new Date(Date.UTC(2026, 0, 5, 9, 0, 0));    // The guard: each of these completes near-instantly and never matches. If the    // parser hung, this test would time out instead of asserting false.    const start = Date.now();    for (const cron of [      "*/0 * * * *",      "*/-5 * * * *",      "0 0 1 1 0-2000000000",      "0-999999999/0 * * * *",    ]) {      expect(cronMatches(cron, date)).toBe(false);    }    // Generous upper bound; the real signal is that it returns at all.    expect(Date.now() - start).toBeLessThan(1000);  });   it("fires a due workflow schedule and starts the run", async () => {    const store = makeInMemoryAppsStore();    const resolver = makeTestResolver({      github: {        "repos.listForAuthenticatedUser": () => [{ full_name: "acme/app" }],        "issues.listForRepo": () => [],      },    });    const host = makeSelfHostAppsRuntime({      dataDir: mkdtempSync(join(tmpdir(), "apps-sched-")),      store,      resolver,      inMemory: true,    });    await run(host.runtime.publish({ scope: "rhys", files: dailyBriefFileSet() }));     const scheduler = makeScheduler({ runtime: host.runtime, scopes: ["rhys"] });    // morning-sync is "0 9 * * 1-5". Tick at a matching minute.    const at = new Date(Date.UTC(2026, 0, 5, 9, 0, 0));    const started = await run(scheduler.tick(at));    expect(started.length).toBe(1);     const run1 = await run(host.runtime.getRun(started[0]));    expect(run1?.workflow).toBe("morning-sync");    expect(run1?.status).toBe("completed");     // A second tick in the same minute does not double-fire.    const again = await run(scheduler.tick(at));    expect(again.length).toBe(0);     await host.close();  });});