diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.test.ts index 830f542ce4b..f26f2bd02f1 100644 --- a/src/gateway/server.config-apply.test.ts +++ b/src/gateway/server.config-apply.test.ts @@ -1,171 +1,148 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { WebSocket } from "ws"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { AUTH_PROFILE_FILENAME } from "../agents/auth-profiles/constants.js"; +import { __testing as controlPlaneRateLimitTesting } from "./control-plane-rate-limit.js"; import { connectOk, - getFreePort, installGatewayTestHooks, - onceMessage, - startGatewayServer, - trackConnectChallengeNonce, + rpcReq, + startServerWithClient, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); -let server: Awaited>; -let port = 0; +let startedServer: Awaited> | null = null; -beforeEach(async () => { - port = await getFreePort(); - server = await startGatewayServer(port, { controlUiEnabled: true }); +beforeAll(async () => { + startedServer = await startServerWithClient(undefined, { controlUiEnabled: true }); + await connectOk(requireWs()); }); -afterEach(async () => { - await server.close(); +afterAll(async () => { + if (!startedServer) { + return; + } + startedServer.ws.close(); + await startedServer.server.close(); + startedServer = null; }); -const openClient = async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - trackConnectChallengeNonce(ws); - await new Promise((resolve) => ws.once("open", resolve)); - await connectOk(ws); - return ws; +function requireWs() { + if (!startedServer) { + throw new Error("gateway test server not started"); + } + return startedServer.ws; +} + +const sendConfigApply = async ( + _id: string, + params: { raw: unknown; baseHash?: string }, + timeoutMs?: number, +) => { + return await rpcReq(requireWs(), "config.apply", params, timeoutMs); }; -const sendConfigApply = async (ws: WebSocket, id: string, raw: unknown) => { - const current = await sendConfigGet(ws, `${id}-base`); - expect(current.ok).toBe(true); - expect(typeof current.payload?.hash).toBe("string"); - ws.send( - JSON.stringify({ - type: "req", - id, - method: "config.apply", - params: { raw, baseHash: current.payload?.hash }, - }), - ); - return onceMessage<{ ok: boolean; error?: { message?: string } }>(ws, (o) => { - const msg = o as { type?: string; id?: string }; - return msg.type === "res" && msg.id === id; - }); -}; - -const sendConfigGet = async (ws: WebSocket, id: string) => { - ws.send( - JSON.stringify({ - type: "req", - id, - method: "config.get", - params: {}, - }), - ); - return onceMessage<{ +const sendConfigGet = async (_id: string) => { + return await rpcReq<{ ok: boolean; payload?: { hash?: string; raw?: string | null; config?: Record }; - }>(ws, (o) => { - const msg = o as { type?: string; id?: string }; - return msg.type === "res" && msg.id === id; - }); + }>(requireWs(), "config.get", {}); }; describe("gateway config.apply", () => { + beforeEach(() => { + controlPlaneRateLimitTesting.resetControlPlaneRateLimitState(); + }); + it("rejects config.apply when SecretRef resolution fails", async () => { - const ws = await openClient(); - try { - const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_APPLY_${Date.now()}`; - delete process.env[missingEnvVar]; - const current = await sendConfigGet(ws, "req-secretref-get-before"); - expect(current.ok).toBe(true); - expect(typeof current.payload?.hash).toBe("string"); - const nextConfig = structuredClone(current.payload?.config ?? {}); - const channels = (nextConfig.channels ??= {}) as Record; - const telegram = (channels.telegram ??= {}) as Record; - telegram.botToken = { source: "env", provider: "default", id: missingEnvVar }; - const telegramAccounts = (telegram.accounts ??= {}) as Record; - const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record; - defaultTelegramAccount.enabled = true; + const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_APPLY_${Date.now()}`; + delete process.env[missingEnvVar]; + const current = await sendConfigGet("req-secretref-get-before"); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + const nextConfig = structuredClone(current.payload?.config ?? {}); + const channels = (nextConfig.channels ??= {}) as Record; + const telegram = (channels.telegram ??= {}) as Record; + telegram.botToken = { source: "env", provider: "default", id: missingEnvVar }; + const telegramAccounts = (telegram.accounts ??= {}) as Record; + const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record; + defaultTelegramAccount.enabled = true; - const id = "req-secretref-apply"; - const res = await sendConfigApply(ws, id, JSON.stringify(nextConfig, null, 2)); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); + const id = "req-secretref-apply"; + const res = await sendConfigApply( + id, + { + raw: JSON.stringify(nextConfig, null, 2), + baseHash: current.payload?.hash, + }, + 20_000, + ); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); - const after = await sendConfigGet(ws, "req-secretref-get-after"); - expect(after.ok).toBe(true); - expect(after.payload?.hash).toBe(current.payload?.hash); - expect(after.payload?.raw).toBe(current.payload?.raw); - } finally { - ws.close(); - } + const after = await sendConfigGet("req-secretref-get-after"); + expect(after.ok).toBe(true); + expect(after.payload?.hash).toBe(current.payload?.hash); + expect(after.payload?.raw).toBe(current.payload?.raw); }); it("does not reject config.apply for unresolved auth-profile refs outside submitted config", async () => { - const ws = await openClient(); - try { - const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_APPLY_${Date.now()}`; - delete process.env[missingEnvVar]; + const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_APPLY_${Date.now()}`; + delete process.env[missingEnvVar]; - const authStorePath = path.join(resolveOpenClawAgentDir(), AUTH_PROFILE_FILENAME); - await fs.mkdir(path.dirname(authStorePath), { recursive: true }); - await fs.writeFile( - authStorePath, - `${JSON.stringify( - { - version: 1, - profiles: { - "custom:token": { - type: "token", - provider: "custom", - tokenRef: { source: "env", provider: "default", id: missingEnvVar }, - }, + const authStorePath = path.join(resolveOpenClawAgentDir(), AUTH_PROFILE_FILENAME); + await fs.mkdir(path.dirname(authStorePath), { recursive: true }); + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "custom:token": { + type: "token", + provider: "custom", + tokenRef: { source: "env", provider: "default", id: missingEnvVar }, }, }, - null, - 2, - )}\n`, - "utf-8", - ); + }, + null, + 2, + )}\n`, + "utf-8", + ); - const current = await sendConfigGet(ws, "req-auth-profile-get-before"); - expect(current.ok).toBe(true); - expect(current.payload?.config).toBeTruthy(); + const current = await sendConfigGet("req-auth-profile-get-before"); + expect(current.ok).toBe(true); + expect(current.payload?.config).toBeTruthy(); - const res = await sendConfigApply( - ws, - "req-auth-profile-apply", - JSON.stringify(current.payload?.config ?? {}, null, 2), - ); - expect(res.ok).toBe(true); - expect(res.error).toBeUndefined(); - } finally { - ws.close(); - } + const res = await sendConfigApply("req-auth-profile-apply", { + raw: JSON.stringify(current.payload?.config ?? {}, null, 2), + baseHash: current.payload?.hash, + }); + expect(res.ok).toBe(true); + expect(res.error).toBeUndefined(); }); it("rejects invalid raw config", async () => { - const ws = await openClient(); - try { - const id = "req-1"; - const res = await sendConfigApply(ws, id, "{"); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toMatch(/invalid|SyntaxError/i); - } finally { - ws.close(); - } + const current = await sendConfigGet("req-invalid-raw-get-before"); + expect(current.ok).toBe(true); + const id = "req-1"; + const res = await sendConfigApply(id, { raw: "{", baseHash: current.payload?.hash }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toMatch(/invalid|SyntaxError/i); }); it("requires raw to be a string", async () => { - const ws = await openClient(); - try { - const id = "req-2"; - const res = await sendConfigApply(ws, id, { gateway: { mode: "local" } }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("raw"); - } finally { - ws.close(); - } + const current = await sendConfigGet("req-non-string-raw-get-before"); + expect(current.ok).toBe(true); + const id = "req-2"; + const res = await sendConfigApply(id, { + raw: { gateway: { mode: "local" } }, + baseHash: current.payload?.hash, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("raw"); }); });