test: share gateway shared auth ws helpers

This commit is contained in:
Peter Steinberger
2026-04-20 19:44:47 +01:00
parent 018494fa3e
commit ee54a8d298
4 changed files with 60 additions and 102 deletions

View File

@@ -3,6 +3,11 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
loadGatewayConfig,
openAuthenticatedGatewayWs,
waitForGatewayWsClose,
} from "./shared-auth.test-helpers.js";
import {
connectOk,
getFreePort,
@@ -33,14 +38,6 @@ afterAll(() => {
}
});
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
async function openDeviceTokenWs(): Promise<WebSocket> {
const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.json`);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
@@ -79,14 +76,6 @@ async function openDeviceTokenWs(): Promise<WebSocket> {
return ws;
}
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
async function closeWsAndWait(ws: WebSocket, timeoutMs = 2_000): Promise<void> {
if (ws.readyState === WebSocket.CLOSED) {
return;
@@ -113,24 +102,8 @@ async function closeWsAndWait(ws: WebSocket, timeoutMs = 2_000): Promise<void> {
});
}
async function loadCurrentConfig(ws: WebSocket): Promise<{
hash: string;
config: Record<string, unknown>;
}> {
const current = await rpcReq<{
hash?: string;
config?: Record<string, unknown>;
}>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
return {
hash: String(current.payload?.hash),
config: structuredClone(current.payload?.config ?? {}),
};
}
async function sendSharedTokenRotationPatch(ws: WebSocket): Promise<{ ok: boolean }> {
const current = await loadCurrentConfig(ws);
const current = await loadGatewayConfig(ws);
return await rpcReq(ws, "config.patch", {
baseHash: current.hash,
raw: JSON.stringify({ gateway: { auth: { token: NEW_TOKEN } } }),
@@ -139,7 +112,7 @@ async function sendSharedTokenRotationPatch(ws: WebSocket): Promise<{ ok: boolea
}
async function applyCurrentConfig(ws: WebSocket) {
const current = await loadCurrentConfig(ws);
const current = await loadGatewayConfig(ws);
return await rpcReq(ws, "config.apply", {
baseHash: current.hash,
raw: JSON.stringify(current.config, null, 2),
@@ -164,9 +137,9 @@ describe("gateway shared auth rotation", () => {
});
it("disconnects existing shared-token websocket sessions after config.patch rotates auth", async () => {
const ws = await openAuthenticatedWs(OLD_TOKEN);
const ws = await openAuthenticatedGatewayWs(port, OLD_TOKEN);
try {
const closed = waitForClose(ws);
const closed = waitForGatewayWsClose(ws);
const res = await sendSharedTokenRotationPatch(ws);
expect(res.ok).toBe(true);
@@ -238,17 +211,13 @@ describe("gateway shared auth rotation with unchanged SecretRefs", () => {
});
async function openSecretRefAuthenticatedWs(): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${secretRefPort}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token: OLD_TOKEN });
return ws;
return openAuthenticatedGatewayWs(secretRefPort, OLD_TOKEN);
}
it("disconnects shared-auth websocket sessions when config.apply rewrites a SecretRef token", async () => {
const ws = await openSecretRefAuthenticatedWs();
try {
const closed = waitForClose(ws);
const closed = waitForGatewayWsClose(ws);
const res = await applyCurrentConfig(ws);
expect(res.ok).toBe(true);
await expect(closed).resolves.toEqual({

View File

@@ -1,14 +1,12 @@
import fs from "node:fs/promises";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { openAuthenticatedGatewayWs, waitForGatewayWsClose } from "./shared-auth.test-helpers.js";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
rpcReq,
startGatewayServer,
testState,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -35,22 +33,6 @@ function buildSharedTokenReloadConfig(): Record<string, unknown> {
};
}
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
beforeAll(async () => {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
@@ -79,9 +61,9 @@ afterAll(async () => {
describe("gateway shared token hot reload rotation", () => {
it("disconnects existing shared-token websocket sessions after hot reload picks up a rotated SecretRef value", async () => {
const ws = await openAuthenticatedWs(OLD_TOKEN);
const ws = await openAuthenticatedGatewayWs(port, OLD_TOKEN);
try {
const closed = waitForClose(ws);
const closed = waitForGatewayWsClose(ws);
process.env[SECRET_REF_TOKEN_ID] = NEW_TOKEN;
const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {}).catch(
(err: unknown) => (err instanceof Error ? err : new Error(String(err))),
@@ -95,7 +77,7 @@ describe("gateway shared token hot reload rotation", () => {
expect(reload.ok).toBe(true);
}
const freshWs = await openAuthenticatedWs(NEW_TOKEN);
const freshWs = await openAuthenticatedGatewayWs(port, NEW_TOKEN);
freshWs.close();
} finally {
ws.close();

View File

@@ -1,14 +1,16 @@
import fs from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
connectOk,
loadGatewayConfig,
openAuthenticatedGatewayWs,
waitForGatewayWsClose,
} from "./shared-auth.test-helpers.js";
import {
getFreePort,
installGatewayTestHooks,
rpcReq,
startGatewayServer,
testState,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -71,45 +73,13 @@ function buildConfigSetWithRotatedToken(config: Record<string, unknown>): Record
return next;
}
async function openAuthenticatedWs(token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
async function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
async function loadCurrentConfig(ws: WebSocket): Promise<{
hash: string;
config: Record<string, unknown>;
}> {
const current = await rpcReq<{
hash?: string;
config?: Record<string, unknown>;
}>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
return {
hash: String(current.payload?.hash),
config: structuredClone(current.payload?.config ?? {}),
};
}
describe("gateway shared token session rotation", () => {
it("invalidates shared-token websocket sessions after config.set rotation even with reload mode off", async () => {
const ws = await openAuthenticatedWs(OLD_TOKEN);
const ws = await openAuthenticatedGatewayWs(port, OLD_TOKEN);
try {
const current = await loadCurrentConfig(ws);
const current = await loadGatewayConfig(ws);
const nextConfig = buildConfigSetWithRotatedToken(current.config);
const closed = waitForClose(ws);
const closed = waitForGatewayWsClose(ws);
const setRes = await rpcReq(ws, "config.set", {
baseHash: current.hash,
raw: JSON.stringify(nextConfig, null, 2),

View File

@@ -0,0 +1,37 @@
import { expect } from "vitest";
import { WebSocket } from "ws";
import { connectOk, rpcReq, trackConnectChallengeNonce } from "./test-helpers.js";
export async function openAuthenticatedGatewayWs(port: number, token: string): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, { token });
return ws;
}
export async function waitForGatewayWsClose(
ws: WebSocket,
): Promise<{ code: number; reason: string }> {
return await new Promise((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
}
export async function loadGatewayConfig(ws: WebSocket): Promise<{
hash: string;
config: Record<string, unknown>;
}> {
const current = await rpcReq<{
hash?: string;
config?: Record<string, unknown>;
}>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
return {
hash: String(current.payload?.hash),
config: structuredClone(current.payload?.config ?? {}),
};
}