Files
openclaw/src/gateway/server-methods.control-plane-rate-limit.test.ts
2026-02-22 20:04:51 +00:00

151 lines
4.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__testing as controlPlaneRateLimitTesting,
resolveControlPlaneRateLimitKey,
} 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<typeof handleGatewayRequest>[0]["context"];
}
function buildConnect(): NonNullable<
Parameters<typeof handleGatewayRequest>[0]["client"]
>["connect"] {
return {
role: "operator",
scopes: ["operator.admin"],
client: {
id: "openclaw-control-ui",
version: "1.0.0",
platform: "darwin",
mode: "ui",
},
minProtocol: 1,
maxProtocol: 1,
};
}
function buildClient() {
return {
connect: buildConnect(),
connId: "conn-1",
clientIp: "10.0.0.5",
} as Parameters<typeof handleGatewayRequest>[0]["client"];
}
async function runRequest(params: {
method: string;
context: Parameters<typeof handleGatewayRequest>[0]["context"];
client: Parameters<typeof handleGatewayRequest>[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);
});
it("uses connId fallback when both device and client IP are unknown", () => {
const key = resolveControlPlaneRateLimitKey({
connect: buildConnect(),
connId: "conn-fallback",
});
expect(key).toBe("unknown-device|unknown-ip|conn=conn-fallback");
});
it("keeps device/IP-based key when identity is present", () => {
const key = resolveControlPlaneRateLimitKey({
connect: buildConnect(),
connId: "conn-fallback",
clientIp: "10.0.0.10",
});
expect(key).toBe("unknown-device|10.0.0.10");
});
});