import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; const enqueueSystemEventMock = vi.fn(); const requestHeartbeatNowMock = vi.fn(); const loadConfigMock = vi.fn(); const fetchWithSsrFGuardMock = vi.fn(); vi.mock("../infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), })); vi.mock("../infra/heartbeat-wake.js", () => ({ requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), })); vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, loadConfig: () => loadConfigMock(), }; }); vi.mock("../infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), })); import { buildGatewayCronService } from "./server-cron.js"; describe("buildGatewayCronService", () => { beforeEach(() => { enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); loadConfigMock.mockClear(); fetchWithSsrFGuardMock.mockClear(); }); it("routes main-target jobs to the scoped session for enqueue + wake", async () => { const tmpDir = path.join(os.tmpdir(), `server-cron-${Date.now()}`); const cfg = { session: { mainKey: "main", }, cron: { store: path.join(tmpDir, "cron.json"), }, } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); const state = buildGatewayCronService({ cfg, deps: {} as CliDeps, broadcast: () => {}, }); try { const job = await state.cron.add({ name: "canonicalize-session-key", enabled: true, schedule: { kind: "at", at: new Date(1).toISOString() }, sessionTarget: "main", wakeMode: "next-heartbeat", sessionKey: "discord:channel:ops", payload: { kind: "systemEvent", text: "hello" }, }); await state.cron.run(job.id, "force"); expect(enqueueSystemEventMock).toHaveBeenCalledWith( "hello", expect.objectContaining({ sessionKey: "agent:main:discord:channel:ops", }), ); expect(requestHeartbeatNowMock).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "agent:main:discord:channel:ops", }), ); } finally { state.cron.stop(); } }); it("blocks private webhook URLs via SSRF-guarded fetch", async () => { const tmpDir = path.join(os.tmpdir(), `server-cron-ssrf-${Date.now()}`); const cfg = { session: { mainKey: "main", }, cron: { store: path.join(tmpDir, "cron.json"), }, } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); fetchWithSsrFGuardMock.mockRejectedValue( new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"), ); const state = buildGatewayCronService({ cfg, deps: {} as CliDeps, broadcast: () => {}, }); try { const job = await state.cron.add({ name: "ssrf-webhook-blocked", enabled: true, schedule: { kind: "at", at: new Date(1).toISOString() }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, delivery: { mode: "webhook", to: "http://127.0.0.1:8080/cron-finished", }, }); await state.cron.run(job.id, "force"); expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce(); expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ url: "http://127.0.0.1:8080/cron-finished", init: { method: "POST", headers: { "Content-Type": "application/json", }, body: expect.stringContaining('"action":"finished"'), signal: expect.any(AbortSignal), }, }); } finally { state.cron.stop(); } }); });