diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts new file mode 100644 index 00000000000..e1058be6a88 --- /dev/null +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { CronJob } from "../../cron/types.js"; + +const loadConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig)); + +vi.mock("../../config/config.js", async () => { + const actual = + await vi.importActual("../../config/config.js"); + return { + ...actual, + loadConfig, + }; +}); + +import { cronHandlers } from "./cron.js"; + +function createCronContext(currentJob?: CronJob) { + return { + cron: { + add: vi.fn(async () => ({ id: "cron-1" })), + update: vi.fn(async () => ({ id: "cron-1" })), + getDefaultAgentId: vi.fn(() => "main"), + getJob: vi.fn(() => currentJob), + }, + logGateway: { + info: vi.fn(), + }, + }; +} + +async function invokeCronAdd(params: Record) { + const context = createCronContext(); + const respond = vi.fn(); + await cronHandlers["cron.add"]({ + req: {} as never, + params: params as never, + respond: respond as never, + context: context as never, + client: null, + isWebchatConnect: () => false, + }); + return { context, respond }; +} + +async function invokeCronUpdate(params: Record, currentJob: CronJob) { + const context = createCronContext(currentJob); + const respond = vi.fn(); + await cronHandlers["cron.update"]({ + req: {} as never, + params: params as never, + respond: respond as never, + context: context as never, + client: null, + isWebchatConnect: () => false, + }); + return { context, respond }; +} + +function createCronJob(overrides: Partial = {}): CronJob { + return { + id: "cron-1", + name: "cron job", + enabled: true, + createdAtMs: 1, + updatedAtMs: 1, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "none" }, + state: {}, + ...overrides, + }; +} + +describe("cron method validation", () => { + beforeEach(() => { + loadConfig.mockReset().mockReturnValue({} as OpenClawConfig); + }); + + it("rejects ambiguous announce delivery on add when multiple channels are configured", async () => { + loadConfig.mockReturnValue({ + session: { + mainKey: "main", + }, + channels: { + telegram: { + botToken: "telegram-token", + }, + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd({ + name: "ambiguous announce add", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "announce" }, + }); + + expect(context.cron.add).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("delivery.channel is required"), + }), + ); + }); + + it("rejects ambiguous announce delivery on update when multiple channels are configured", async () => { + loadConfig.mockReturnValue({ + session: { + mainKey: "main", + }, + channels: { + telegram: { + botToken: "telegram-token", + }, + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { + delivery: { mode: "announce" }, + }, + }, + createCronJob(), + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("delivery.channel is required"), + }), + ); + }); + + it("rejects target ids mistakenly supplied as delivery.channel providers", async () => { + loadConfig.mockReturnValue({ + session: { + mainKey: "main", + }, + channels: { + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + }, + plugins: { + entries: { + slack: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd({ + name: "invalid delivery provider", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { + mode: "announce", + channel: "C0AT2Q238MQ", + to: "C0AT2Q238MQ", + }, + }); + + expect(context.cron.add).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("delivery.channel must be one of: slack"), + }), + ); + }); +}); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 79c4e1f1398..8956010fbdc 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -721,54 +721,6 @@ describe("gateway server cron", () => { } }); - test("rejects ambiguous announce delivery on add when multiple channels are configured", async () => { - const { prevSkipCron } = await setupCronTestRun({ - tempPrefix: "openclaw-gw-cron-ambiguous-delivery-add-", - cronEnabled: false, - }); - - await writeCronConfig({ - session: { - mainKey: "main", - }, - channels: { - telegram: { - botToken: "telegram-token", - }, - slack: { - botToken: "xoxb-slack-token", - appToken: "xapp-slack-token", - }, - }, - plugins: { - entries: { - telegram: { enabled: true }, - slack: { enabled: true }, - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - try { - const addRes = await rpcReq(ws, "cron.add", { - name: "ambiguous announce add", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "hello" }, - delivery: { mode: "announce" }, - }); - - expect(addRes.ok).toBe(false); - expect(addRes.error?.message).toContain("delivery.channel is required"); - } finally { - await cleanupCronTestRun({ ws, server, prevSkipCron }); - } - }); - test("ignores ambient disabled channel env when validating announce delivery", async () => { vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-ambient"); vi.stubEnv("TELEGRAM_BOT_TOKEN", "ambient-telegram"); @@ -806,114 +758,6 @@ describe("gateway server cron", () => { } }); - test("rejects ambiguous announce delivery on update when multiple channels are configured", async () => { - const { prevSkipCron } = await setupCronTestRun({ - tempPrefix: "openclaw-gw-cron-ambiguous-delivery-update-", - cronEnabled: false, - }); - - await writeCronConfig({ - session: { - mainKey: "main", - }, - channels: { - telegram: { - botToken: "telegram-token", - }, - slack: { - botToken: "xoxb-slack-token", - appToken: "xapp-slack-token", - }, - }, - plugins: { - entries: { - telegram: { enabled: true }, - slack: { enabled: true }, - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - try { - const addRes = await rpcReq(ws, "cron.add", { - name: "ambiguous announce update", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "hello" }, - delivery: { mode: "none" }, - }); - expect(addRes.ok).toBe(true); - const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; - const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; - expect(jobId.length > 0).toBe(true); - - const updateRes = await rpcReq(ws, "cron.update", { - id: jobId, - patch: { - delivery: { mode: "announce" }, - }, - }); - - expect(updateRes.ok).toBe(false); - expect(updateRes.error?.message).toContain("delivery.channel is required"); - } finally { - await cleanupCronTestRun({ ws, server, prevSkipCron }); - } - }); - - test("rejects target ids mistakenly supplied as delivery.channel providers", async () => { - const { prevSkipCron } = await setupCronTestRun({ - tempPrefix: "openclaw-gw-cron-invalid-delivery-provider-", - cronEnabled: false, - }); - - await writeCronConfig({ - session: { - mainKey: "main", - }, - channels: { - slack: { - botToken: "xoxb-slack-token", - appToken: "xapp-slack-token", - }, - }, - plugins: { - entries: { - slack: { enabled: true }, - }, - }, - }); - - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - - try { - const addRes = await rpcReq(ws, "cron.add", { - name: "invalid delivery provider", - enabled: true, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "hello" }, - delivery: { - mode: "announce", - channel: "C0AT2Q238MQ", - to: "C0AT2Q238MQ", - }, - }); - - expect(addRes.ok).toBe(false); - expect(addRes.error?.message).toContain("delivery.channel"); - expect(addRes.error?.message).toContain("slack"); - } finally { - await cleanupCronTestRun({ ws, server, prevSkipCron }); - } - }); - test("writes cron run history and auto-runs due jobs", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-log-",