import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getHandshakeTimeoutMs } from "./server-constants.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; import { connectReq, getFreePort, installGatewayTestHooks, onceMessage, startGatewayServer, startServerWithClient, testState, } from "./test-helpers.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; installGatewayTestHooks({ scope: "suite" }); async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise { if (ws.readyState === WebSocket.CLOSED) return true; return await new Promise((resolve) => { const timer = setTimeout(() => resolve(ws.readyState === WebSocket.CLOSED), timeoutMs); ws.once("close", () => { clearTimeout(timer); resolve(true); }); }); } const openWs = async (port: number) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); return ws; }; describe("gateway server auth/connect", () => { describe("default auth", () => { let server: Awaited>; let port: number; beforeAll(async () => { port = await getFreePort(); server = await startGatewayServer(port); }); afterAll(async () => { await server.close(); }); test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { vi.useRealTimers(); const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50"; try { const ws = await openWs(port); const handshakeTimeoutMs = getHandshakeTimeoutMs(); const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250); expect(closed).toBe(true); } finally { if (prevHandshakeTimeout === undefined) { delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; } else { process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; } } }); test("connect (req) handshake returns hello-ok payload", async () => { const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js"); const ws = await openWs(port); const res = await connectReq(ws); expect(res.ok).toBe(true); const payload = res.payload as | { type?: unknown; snapshot?: { configPath?: string; stateDir?: string }; } | undefined; expect(payload?.type).toBe("hello-ok"); expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT); expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT); ws.close(); }); test("sends connect challenge on open", async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}`); const evtPromise = onceMessage<{ payload?: unknown }>( ws, (o) => o.type === "event" && o.event === "connect.challenge", ); await new Promise((resolve) => ws.once("open", resolve)); const evt = await evtPromise; const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; expect(typeof nonce).toBe("string"); ws.close(); }); test("rejects protocol mismatch", async () => { const ws = await openWs(port); try { const res = await connectReq(ws, { minProtocol: PROTOCOL_VERSION + 1, maxProtocol: PROTOCOL_VERSION + 2, }); expect(res.ok).toBe(false); } catch { // If the server closed before we saw the frame, that's acceptable. } ws.close(); }); test("rejects non-connect first request", async () => { const ws = await openWs(port); ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" })); const res = await onceMessage<{ ok: boolean; error?: unknown }>( ws, (o) => o.type === "res" && o.id === "h1", ); expect(res.ok).toBe(false); await new Promise((resolve) => ws.once("close", () => resolve())); }); test( "invalid connect params surface in response and close reason", { timeout: 60_000 }, async () => { const ws = await openWs(port); const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); }); ws.send( JSON.stringify({ type: "req", id: "h-bad", method: "connect", params: { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { id: "bad-client", version: "dev", platform: "web", mode: "webchat", }, device: { id: 123, publicKey: "bad", signature: "bad", signedAt: "bad", }, }, }), ); const res = await onceMessage<{ ok: boolean; error?: { message?: string }; }>( ws, (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", ); expect(res.ok).toBe(false); expect(String(res.error?.message ?? "")).toContain("invalid connect params"); const closeInfo = await closeInfoPromise; expect(closeInfo.code).toBe(1008); expect(closeInfo.reason).toContain("invalid connect params"); }, ); }); describe("password auth", () => { let server: Awaited>; let port: number; beforeAll(async () => { testState.gatewayAuth = { mode: "password", password: "secret" }; port = await getFreePort(); server = await startGatewayServer(port); }); afterAll(async () => { await server.close(); }); test("accepts password auth when configured", async () => { const ws = await openWs(port); const res = await connectReq(ws, { password: "secret" }); expect(res.ok).toBe(true); ws.close(); }); test("rejects invalid password", async () => { const ws = await openWs(port); const res = await connectReq(ws, { password: "wrong" }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("unauthorized"); ws.close(); }); }); describe("token auth", () => { let server: Awaited>; let port: number; let prevToken: string | undefined; beforeAll(async () => { prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; port = await getFreePort(); server = await startGatewayServer(port); }); afterAll(async () => { await server.close(); if (prevToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); test("rejects invalid token", async () => { const ws = await openWs(port); const res = await connectReq(ws, { token: "wrong" }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("unauthorized"); ws.close(); }); test("returns control ui hint when token is missing", async () => { const ws = await openWs(port); const res = await connectReq(ws, { client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "1.0.0", platform: "web", mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("Control UI settings"); ws.close(); }); test("rejects control ui without device identity by default", async () => { const ws = await openWs(port); const res = await connectReq(ws, { token: "secret", device: null, client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "1.0.0", platform: "web", mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("secure context"); ws.close(); }); }); test("allows control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, prevToken } = await startServerWithClient("secret"); const res = await connectReq(ws, { token: "secret", device: null, client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "1.0.0", platform: "web", mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); expect(res.ok).toBe(true); ws.close(); await server.close(); if (prevToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); test("allows control ui with device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ gateway: { trustedProxies: ["127.0.0.1"], }, } as any); const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; const port = await getFreePort(); const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { "x-forwarded-for": "203.0.113.10" }, }); const challengePromise = onceMessage<{ payload?: unknown }>( ws, (o) => o.type === "event" && o.event === "connect.challenge", ); await new Promise((resolve) => ws.once("open", resolve)); const challenge = await challengePromise; const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; expect(typeof nonce).toBe("string"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = await import("../infra/device-identity.js"); const identity = loadOrCreateDeviceIdentity(); const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, role: "operator", scopes: [], signedAtMs, token: "secret", nonce: String(nonce), }); const device = { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, nonce: String(nonce), }; const res = await connectReq(ws, { token: "secret", device, client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "1.0.0", platform: "web", mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); expect(res.ok).toBe(true); ws.close(); await server.close(); if (prevToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const res = await connectReq(ws, { token: "secret" }); if (!res.ok) { const list = await listDevicePairing(); const pending = list.pending.at(0); expect(pending?.requestId).toBeDefined(); if (pending?.requestId) { await approveDevicePairing(pending.requestId); } } const identity = loadOrCreateDeviceIdentity(); const paired = await getPairedDevice(identity.deviceId); const deviceToken = paired?.tokens?.operator?.token; expect(deviceToken).toBeDefined(); ws.close(); const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws2.once("open", resolve)); const res2 = await connectReq(ws2, { token: deviceToken }); expect(res2.ok).toBe(true); ws2.close(); await server.close(); if (prevToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); test("requires pairing for scope upgrades", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const { buildDeviceAuthPayload } = await import("./device-auth.js"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } = await import("../utils/message-channel.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const identityDir = await mkdtemp(join(tmpdir(), "clawdbot-device-scope-")); const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); const client = { id: GATEWAY_CLIENT_NAMES.TEST, version: "1.0.0", platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, }; const buildDevice = (scopes: string[]) => { const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, clientId: client.id, clientMode: client.mode, role: "operator", scopes, signedAtMs, token: "secret", }); return { id: identity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), signature: signDevicePayload(identity.privateKeyPem, payload), signedAt: signedAtMs, }; }; const initial = await connectReq(ws, { token: "secret", scopes: ["operator.read"], client, device: buildDevice(["operator.read"]), }); if (!initial.ok) { const list = await listDevicePairing(); const pending = list.pending.at(0); expect(pending?.requestId).toBeDefined(); if (pending?.requestId) { await approveDevicePairing(pending.requestId); } } let paired = await getPairedDevice(identity.deviceId); expect(paired?.scopes).toContain("operator.read"); ws.close(); const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws2.once("open", resolve)); const res = await connectReq(ws2, { token: "secret", scopes: ["operator.admin"], client, device: buildDevice(["operator.admin"]), }); expect(res.ok).toBe(true); paired = await getPairedDevice(identity.deviceId); expect(paired?.scopes).toContain("operator.admin"); ws2.close(); await server.close(); if (prevToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); test("rejects revoked device token", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const res = await connectReq(ws, { token: "secret" }); if (!res.ok) { const list = await listDevicePairing(); const pending = list.pending.at(0); expect(pending?.requestId).toBeDefined(); if (pending?.requestId) { await approveDevicePairing(pending.requestId); } } const identity = loadOrCreateDeviceIdentity(); const paired = await getPairedDevice(identity.deviceId); const deviceToken = paired?.tokens?.operator?.token; expect(deviceToken).toBeDefined(); await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" }); ws.close(); const ws2 = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws2.once("open", resolve)); const res2 = await connectReq(ws2, { token: deviceToken }); expect(res2.ok).toBe(false); ws2.close(); await server.close(); if (prevToken === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); // Remaining tests require isolated gateway state. });