import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing as controlPlaneRateLimitTesting } from "./control-plane-rate-limit.js"; import { handleGatewayRequest } from "./server-methods.js"; import type { GatewayRequestHandler } from "./server-methods/types.js"; const noWebchat = () => false; describe("gateway control-plane write rate limit", () => { beforeEach(() => { controlPlaneRateLimitTesting.resetControlPlaneRateLimitState(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-19T00:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); controlPlaneRateLimitTesting.resetControlPlaneRateLimitState(); }); function buildContext(logWarn = vi.fn()) { return { logGateway: { warn: logWarn, }, } as unknown as Parameters[0]["context"]; } function buildClient() { return { connect: { role: "operator", scopes: ["operator.admin"], client: { id: "openclaw-control-ui", version: "1.0.0", platform: "darwin", mode: "ui", }, minProtocol: 1, maxProtocol: 1, }, connId: "conn-1", clientIp: "10.0.0.5", } as Parameters[0]["client"]; } async function runRequest(params: { method: string; context: Parameters[0]["context"]; client: Parameters[0]["client"]; handler: GatewayRequestHandler; }) { const respond = vi.fn(); await handleGatewayRequest({ req: { type: "req", id: crypto.randomUUID(), method: params.method, }, respond, client: params.client, isWebchatConnect: noWebchat, context: params.context, extraHandlers: { [params.method]: params.handler, }, }); return respond; } it("allows 3 control-plane writes and blocks the 4th in the same minute", async () => { const handlerCalls = vi.fn(); const handler: GatewayRequestHandler = (opts) => { handlerCalls(opts); opts.respond(true, undefined, undefined); }; const logWarn = vi.fn(); const context = buildContext(logWarn); const client = buildClient(); await runRequest({ method: "config.patch", context, client, handler }); await runRequest({ method: "config.patch", context, client, handler }); await runRequest({ method: "config.patch", context, client, handler }); const blocked = await runRequest({ method: "config.patch", context, client, handler }); expect(handlerCalls).toHaveBeenCalledTimes(3); expect(blocked).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: "UNAVAILABLE", retryable: true, }), ); expect(logWarn).toHaveBeenCalledTimes(1); }); it("resets the control-plane write budget after 60 seconds", async () => { const handlerCalls = vi.fn(); const handler: GatewayRequestHandler = (opts) => { handlerCalls(opts); opts.respond(true, undefined, undefined); }; const context = buildContext(); const client = buildClient(); await runRequest({ method: "update.run", context, client, handler }); await runRequest({ method: "update.run", context, client, handler }); await runRequest({ method: "update.run", context, client, handler }); const blocked = await runRequest({ method: "update.run", context, client, handler }); expect(blocked).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: "UNAVAILABLE" }), ); vi.advanceTimersByTime(60_001); const allowed = await runRequest({ method: "update.run", context, client, handler }); expect(allowed).toHaveBeenCalledWith(true, undefined, undefined); expect(handlerCalls).toHaveBeenCalledTimes(4); }); });