From 03ea6953e00d658978dc4157328f5641512dc543 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Mar 2026 16:36:03 +0000 Subject: [PATCH] test: share gateway authz and watchdog fixtures --- src/gateway/client.watchdog.test.ts | 139 +++++++---------- src/gateway/device-authz.test-helpers.ts | 121 +++++++++++++++ src/gateway/server-plugins.test.ts | 143 ++++++------------ .../server.device-pair-approve-authz.test.ts | 92 ++--------- .../server.device-token-rotate-authz.test.ts | 143 ++++-------------- ...silent-scope-upgrade-reconnect.poc.test.ts | 94 ++---------- 6 files changed, 279 insertions(+), 453 deletions(-) create mode 100644 src/gateway/device-authz.test-helpers.ts diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 456ad5a368f..5c217b8bdc4 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -21,6 +21,43 @@ async function getFreePort(): Promise { }); } +function createOpenGatewayClient(requestTimeoutMs: number): { + client: GatewayClient; + send: ReturnType; +} { + const client = new GatewayClient({ + requestTimeoutMs, + }); + const send = vi.fn(); + ( + client as unknown as { + ws: WebSocket | { readyState: number; send: () => void; close: () => void }; + } + ).ws = { + readyState: WebSocket.OPEN, + send, + close: vi.fn(), + }; + return { client, send }; +} + +function getPendingCount(client: GatewayClient): number { + return (client as unknown as { pending: Map }).pending.size; +} + +function trackSettlement(promise: Promise): () => boolean { + let settled = false; + void promise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + return () => settled; +} + describe("GatewayClient", () => { let wss: WebSocketServer | null = null; let httpsServer: ReturnType | null = null; @@ -111,31 +148,19 @@ describe("GatewayClient", () => { test("times out unresolved requests and clears pending state", async () => { vi.useFakeTimers(); try { - const client = new GatewayClient({ - requestTimeoutMs: 25, - }); - const send = vi.fn(); - ( - client as unknown as { - ws: WebSocket | { readyState: number; send: () => void; close: () => void }; - } - ).ws = { - readyState: WebSocket.OPEN, - send, - close: vi.fn(), - }; + const { client, send } = createOpenGatewayClient(25); const requestPromise = client.request("status"); const requestExpectation = expect(requestPromise).rejects.toThrow( "gateway request timeout for status", ); expect(send).toHaveBeenCalledTimes(1); - expect((client as unknown as { pending: Map }).pending.size).toBe(1); + expect(getPendingCount(client)).toBe(1); await vi.advanceTimersByTimeAsync(25); await requestExpectation; - expect((client as unknown as { pending: Map }).pending.size).toBe(0); + expect(getPendingCount(client)).toBe(0); } finally { vi.useRealTimers(); } @@ -144,36 +169,16 @@ describe("GatewayClient", () => { test("does not auto-timeout expectFinal requests", async () => { vi.useFakeTimers(); try { - const client = new GatewayClient({ - requestTimeoutMs: 25, - }); - const send = vi.fn(); - ( - client as unknown as { - ws: WebSocket | { readyState: number; send: () => void; close: () => void }; - } - ).ws = { - readyState: WebSocket.OPEN, - send, - close: vi.fn(), - }; + const { client, send } = createOpenGatewayClient(25); - let settled = false; const requestPromise = client.request("chat.send", undefined, { expectFinal: true }); - void requestPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); + const isSettled = trackSettlement(requestPromise); expect(send).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(25); - expect(settled).toBe(false); - expect((client as unknown as { pending: Map }).pending.size).toBe(1); + expect(isSettled()).toBe(false); + expect(getPendingCount(client)).toBe(1); client.stop(); await expect(requestPromise).rejects.toThrow("gateway client stopped"); @@ -185,35 +190,15 @@ describe("GatewayClient", () => { test("clamps oversized explicit request timeouts before scheduling", async () => { vi.useFakeTimers(); try { - const client = new GatewayClient({ - requestTimeoutMs: 25, - }); - const send = vi.fn(); - ( - client as unknown as { - ws: WebSocket | { readyState: number; send: () => void; close: () => void }; - } - ).ws = { - readyState: WebSocket.OPEN, - send, - close: vi.fn(), - }; + const { client } = createOpenGatewayClient(25); - let settled = false; const requestPromise = client.request("status", undefined, { timeoutMs: 2_592_010_000 }); - void requestPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); + const isSettled = trackSettlement(requestPromise); await vi.advanceTimersByTimeAsync(1); - expect(settled).toBe(false); - expect((client as unknown as { pending: Map }).pending.size).toBe(1); + expect(isSettled()).toBe(false); + expect(getPendingCount(client)).toBe(1); client.stop(); await expect(requestPromise).rejects.toThrow("gateway client stopped"); @@ -225,35 +210,15 @@ describe("GatewayClient", () => { test("clamps oversized default request timeouts before scheduling", async () => { vi.useFakeTimers(); try { - const client = new GatewayClient({ - requestTimeoutMs: 2_592_010_000, - }); - const send = vi.fn(); - ( - client as unknown as { - ws: WebSocket | { readyState: number; send: () => void; close: () => void }; - } - ).ws = { - readyState: WebSocket.OPEN, - send, - close: vi.fn(), - }; + const { client } = createOpenGatewayClient(2_592_010_000); - let settled = false; const requestPromise = client.request("status"); - void requestPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); + const isSettled = trackSettlement(requestPromise); await vi.advanceTimersByTimeAsync(1); - expect(settled).toBe(false); - expect((client as unknown as { pending: Map }).pending.size).toBe(1); + expect(isSettled()).toBe(false); + expect(getPendingCount(client)).toBe(1); client.stop(); await expect(requestPromise).rejects.toThrow("gateway client stopped"); diff --git a/src/gateway/device-authz.test-helpers.ts b/src/gateway/device-authz.test-helpers.ts new file mode 100644 index 00000000000..6cd0ecd5010 --- /dev/null +++ b/src/gateway/device-authz.test-helpers.ts @@ -0,0 +1,121 @@ +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; +import { WebSocket } from "ws"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + type DeviceIdentity, +} from "../infra/device-identity.js"; +import { + approveDevicePairing, + getPairedDevice, + requestDevicePairing, + rotateDeviceToken, +} from "../infra/device-pairing.js"; +import { trackConnectChallengeNonce } from "./test-helpers.js"; + +export function resolveDeviceIdentityPath(name: string): string { + const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir(); + return path.join(root, "test-device-identities", `${name}.json`); +} + +export function loadDeviceIdentity(name: string): { + identityPath: string; + identity: DeviceIdentity; + publicKey: string; +} { + const identityPath = resolveDeviceIdentityPath(name); + const identity = loadOrCreateDeviceIdentity(identityPath); + return { + identityPath, + identity, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + }; +} + +export async function pairDeviceIdentity(params: { + name: string; + role: "node" | "operator"; + scopes: string[]; + clientId?: string; + clientMode?: string; +}): Promise<{ + identityPath: string; + identity: DeviceIdentity; + publicKey: string; +}> { + const loaded = loadDeviceIdentity(params.name); + const request = await requestDevicePairing({ + deviceId: loaded.identity.deviceId, + publicKey: loaded.publicKey, + role: params.role, + scopes: params.scopes, + clientId: params.clientId, + clientMode: params.clientMode, + }); + await approveDevicePairing(request.request.requestId); + return loaded; +} + +export async function issueOperatorToken(params: { + name: string; + approvedScopes: string[]; + tokenScopes?: string[]; + clientId?: string; + clientMode?: string; +}): Promise<{ + deviceId: string; + identityPath: string; + token: string; +}> { + const paired = await pairDeviceIdentity({ + name: params.name, + role: "operator", + scopes: params.approvedScopes, + clientId: params.clientId, + clientMode: params.clientMode, + }); + if (params.tokenScopes) { + const rotated = await rotateDeviceToken({ + deviceId: paired.identity.deviceId, + role: "operator", + scopes: params.tokenScopes, + }); + expect(rotated.ok).toBe(true); + const token = rotated.ok ? rotated.entry.token : ""; + expect(token).toBeTruthy(); + return { + deviceId: paired.identity.deviceId, + identityPath: paired.identityPath, + token, + }; + } + + const device = await getPairedDevice(paired.identity.deviceId); + const token = device?.tokens?.operator?.token ?? ""; + expect(token).toBeTruthy(); + expect(device?.approvedScopes).toEqual(params.approvedScopes); + return { + deviceId: paired.identity.deviceId, + identityPath: paired.identityPath, + token, + }; +} + +export async function openTrackedWs(port: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + trackConnectChallengeNonce(ws); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000); + ws.once("open", () => { + clearTimeout(timer); + resolve(); + }); + ws.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); + return ws; +} diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index c59b14d7454..41a8185a2c7 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -83,6 +83,15 @@ let runtimeModule: PluginRuntimeModule; let gatewayRequestScopeModule: GatewayRequestScopeModule; let methodScopesModule: MethodScopesModule; +function createTestLog() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; +} + function createTestContext(label: string): GatewayRequestContext { return { label } as unknown as GatewayRequestContext; } @@ -115,12 +124,7 @@ async function createSubagentRuntime( _serverPlugins: ServerPluginsModule, cfg: Record = {}, ): Promise { - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; + const log = createTestLog(); loadOpenClawPlugins.mockReturnValue(createRegistry([])); serverPluginBootstrapModule.loadGatewayStartupPlugins({ cfg, @@ -144,6 +148,36 @@ async function reloadServerPluginsModule(): Promise { return serverPluginsModule; } +function loadGatewayPluginsForTest( + overrides: Partial[0]> = {}, +) { + const log = createTestLog(); + serverPluginsModule.loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + ...overrides, + }); + return log; +} + +function loadGatewayStartupPluginsForTest( + overrides: Partial[0]> = {}, +) { + const log = createTestLog(); + serverPluginBootstrapModule.loadGatewayStartupPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log, + coreGatewayHandlers: {}, + baseMethods: [], + ...overrides, + }); + return log; +} + beforeAll(async () => { await loadTestModules(); }); @@ -180,7 +214,6 @@ afterEach(() => { describe("loadGatewayPlugins", () => { test("logs plugin errors with details", async () => { - const { loadGatewayStartupPlugins } = serverPluginBootstrapModule; const diagnostics: PluginDiagnostic[] = [ { level: "error", @@ -190,21 +223,7 @@ describe("loadGatewayPlugins", () => { }, ]; loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics)); - - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - loadGatewayStartupPlugins({ - cfg: {}, - workspaceDir: "/tmp", - log, - coreGatewayHandlers: {}, - baseMethods: [], - }); + const log = loadGatewayStartupPluginsForTest(); expect(log.error).toHaveBeenCalledWith( "[plugins] failed to load plugin: boom (plugin=telegram, source=/tmp/telegram/index.ts)", @@ -213,23 +232,8 @@ describe("loadGatewayPlugins", () => { }); test("loads only gateway startup plugin ids", async () => { - const { loadGatewayPlugins } = serverPluginsModule; loadOpenClawPlugins.mockReturnValue(createRegistry([])); - - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - loadGatewayPlugins({ - cfg: {}, - workspaceDir: "/tmp", - log, - coreGatewayHandlers: {}, - baseMethods: [], - }); + loadGatewayPluginsForTest(); expect(resolveGatewayStartupPluginIds).toHaveBeenCalledWith({ config: {}, @@ -244,23 +248,8 @@ describe("loadGatewayPlugins", () => { }); test("provides subagent runtime with sessions.get method aliases", async () => { - const { loadGatewayPlugins } = serverPluginsModule; loadOpenClawPlugins.mockReturnValue(createRegistry([])); - - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - loadGatewayPlugins({ - cfg: {}, - workspaceDir: "/tmp", - log, - coreGatewayHandlers: {}, - baseMethods: [], - }); + loadGatewayPluginsForTest(); const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as | { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } } @@ -495,22 +484,8 @@ describe("loadGatewayPlugins", () => { }); test("can prefer setup-runtime channel plugins during startup loads", async () => { - const { loadGatewayPlugins } = serverPluginsModule; loadOpenClawPlugins.mockReturnValue(createRegistry([])); - - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - - loadGatewayPlugins({ - cfg: {}, - workspaceDir: "/tmp", - log, - coreGatewayHandlers: {}, - baseMethods: [], + loadGatewayPluginsForTest({ preferSetupRuntimeForChannelPlugins: true, }); @@ -522,24 +497,9 @@ describe("loadGatewayPlugins", () => { }); test("primes configured bindings during gateway startup", async () => { - const { loadGatewayStartupPlugins } = serverPluginBootstrapModule; loadOpenClawPlugins.mockReturnValue(createRegistry([])); - - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - const cfg = {}; - loadGatewayStartupPlugins({ - cfg, - workspaceDir: "/tmp", - log, - coreGatewayHandlers: {}, - baseMethods: [], - }); + loadGatewayStartupPluginsForTest({ cfg }); expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg }); }); @@ -555,13 +515,7 @@ describe("loadGatewayPlugins", () => { }, ]; loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics)); - - const log = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; + const log = createTestLog(); reloadDeferredGatewayPlugins({ cfg: {}, @@ -590,10 +544,7 @@ describe("loadGatewayPlugins", () => { cfg: {}, workspaceDir: "/tmp", log: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), + ...createTestLog(), }, coreGatewayHandlers: {}, baseMethods: [], diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts index 9ed5ce0950d..a13f17e593c 100644 --- a/src/gateway/server.device-pair-approve-authz.test.ts +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -1,97 +1,31 @@ -import os from "node:os"; -import path from "node:path"; import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - type DeviceIdentity, -} from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, - rotateDeviceToken, -} from "../infra/device-pairing.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + issueOperatorToken, + loadDeviceIdentity, + openTrackedWs, +} from "./device-authz.test-helpers.js"; import { connectOk, installGatewayTestHooks, rpcReq, startServerWithClient, - trackConnectChallengeNonce, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); -function resolveDeviceIdentityPath(name: string): string { - const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir(); - return path.join(root, "test-device-identities", `${name}.json`); -} - -function loadDeviceIdentity(name: string): { - identityPath: string; - identity: DeviceIdentity; - publicKey: string; -} { - const identityPath = resolveDeviceIdentityPath(name); - const identity = loadOrCreateDeviceIdentity(identityPath); - return { - identityPath, - identity, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - }; -} - -async function issuePairingScopedOperator(name: string): Promise<{ - identityPath: string; - deviceId: string; - token: string; -}> { - const loaded = loadDeviceIdentity(name); - const request = await requestDevicePairing({ - deviceId: loaded.identity.deviceId, - publicKey: loaded.publicKey, - role: "operator", - scopes: ["operator.admin"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - }); - await approveDevicePairing(request.request.requestId); - const rotated = await rotateDeviceToken({ - deviceId: loaded.identity.deviceId, - role: "operator", - scopes: ["operator.pairing"], - }); - expect(rotated.ok ? rotated.entry.token : "").toBeTruthy(); - return { - identityPath: loaded.identityPath, - deviceId: loaded.identity.deviceId, - token: rotated.ok ? rotated.entry.token : "", - }; -} - -async function openTrackedWs(port: number): Promise { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000); - ws.once("open", () => { - clearTimeout(timer); - resolve(); - }); - ws.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - }); - return ws; -} - describe("gateway device.pair.approve caller scope guard", () => { test("rejects approving device scopes above the caller session scopes", async () => { const started = await startServerWithClient("secret"); - const approver = await issuePairingScopedOperator("approve-attacker"); + const approver = await issueOperatorToken({ + name: "approve-attacker", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); const pending = loadDeviceIdentity("approve-target"); let pairingWs: WebSocket | undefined; diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 7cd7ce20865..e47f968b042 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -1,119 +1,22 @@ -import os from "node:os"; -import path from "node:path"; import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - type DeviceIdentity, -} from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, - rotateDeviceToken, -} from "../infra/device-pairing.js"; +import { getPairedDevice } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; +import { + issueOperatorToken, + openTrackedWs, + pairDeviceIdentity, +} from "./device-authz.test-helpers.js"; import { connectOk, installGatewayTestHooks, rpcReq, startServerWithClient, - trackConnectChallengeNonce, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); -function resolveDeviceIdentityPath(name: string): string { - const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir(); - return path.join(root, "test-device-identities", `${name}.json`); -} - -function loadDeviceIdentity(name: string): { - identityPath: string; - identity: DeviceIdentity; - publicKey: string; -} { - const identityPath = resolveDeviceIdentityPath(name); - const identity = loadOrCreateDeviceIdentity(identityPath); - return { - identityPath, - identity, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - }; -} - -async function pairDevice(params: { - name: string; - role: "node" | "operator"; - scopes: string[]; - clientId?: string; - clientMode?: string; -}): Promise<{ - identityPath: string; - identity: DeviceIdentity; -}> { - const loaded = loadDeviceIdentity(params.name); - const request = await requestDevicePairing({ - deviceId: loaded.identity.deviceId, - publicKey: loaded.publicKey, - role: params.role, - scopes: params.scopes, - clientId: params.clientId, - clientMode: params.clientMode, - }); - await approveDevicePairing(request.request.requestId); - return { - identityPath: loaded.identityPath, - identity: loaded.identity, - }; -} - -async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Promise<{ - deviceId: string; - identityPath: string; - pairingToken: string; -}> { - const paired = await pairDevice({ - name, - role: "operator", - scopes: ["operator.admin"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - }); - const rotated = await rotateDeviceToken({ - deviceId: paired.identity.deviceId, - role: "operator", - scopes: ["operator.pairing"], - }); - expect(rotated.ok).toBe(true); - const pairingToken = rotated.ok ? rotated.entry.token : ""; - expect(pairingToken).toBeTruthy(); - return { - deviceId: paired.identity.deviceId, - identityPath: paired.identityPath, - pairingToken, - }; -} - -async function openTrackedWs(port: number): Promise { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000); - ws.once("open", () => { - clearTimeout(timer); - resolve(); - }); - ws.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - }); - return ws; -} - async function connectPairingScopedOperator(params: { port: number; identityPath: string; @@ -134,7 +37,7 @@ async function connectApprovedNode(params: { name: string; onInvoke: (payload: unknown) => void; }): Promise { - const paired = await pairDevice({ + const paired = await pairDeviceIdentity({ name: params.name, role: "node", scopes: [], @@ -207,14 +110,20 @@ async function waitForMacrotasks(): Promise { describe("gateway device.token.rotate caller scope guard", () => { test("rejects rotating an admin-approved device token above the caller session scopes", async () => { const started = await startServerWithClient("secret"); - const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-attacker"); + const attacker = await issueOperatorToken({ + name: "rotate-attacker", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); let pairingWs: WebSocket | undefined; try { pairingWs = await connectPairingScopedOperator({ port: started.port, identityPath: attacker.identityPath, - deviceToken: attacker.pairingToken, + deviceToken: attacker.token, }); const rotate = await rpcReq(pairingWs, "device.token.rotate", { @@ -238,7 +147,13 @@ describe("gateway device.token.rotate caller scope guard", () => { test("blocks the pairing-token to admin-node-invoke escalation chain", async () => { const started = await startServerWithClient("secret"); - const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-rce-attacker"); + const attacker = await issueOperatorToken({ + name: "rotate-rce-attacker", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); let sawInvoke = false; let pairingWs: WebSocket | undefined; @@ -258,7 +173,7 @@ describe("gateway device.token.rotate caller scope guard", () => { pairingWs = await connectPairingScopedOperator({ port: started.port, identityPath: attacker.identityPath, - deviceToken: attacker.pairingToken, + deviceToken: attacker.token, }); const rotate = await rpcReq<{ token?: string }>(pairingWs, "device.token.rotate", { @@ -274,7 +189,7 @@ describe("gateway device.token.rotate caller scope guard", () => { const paired = await getPairedDevice(attacker.deviceId); expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]); - expect(paired?.tokens?.operator?.token).toBe(attacker.pairingToken); + expect(paired?.tokens?.operator?.token).toBe(attacker.token); } finally { pairingWs?.close(); nodeClient?.stop(); @@ -286,14 +201,20 @@ describe("gateway device.token.rotate caller scope guard", () => { test("returns the same public deny for unknown devices and caller scope failures", async () => { const started = await startServerWithClient("secret"); - const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-deny-shape"); + const attacker = await issueOperatorToken({ + name: "rotate-deny-shape", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); let pairingWs: WebSocket | undefined; try { pairingWs = await connectPairingScopedOperator({ port: started.port, identityPath: attacker.identityPath, - deviceToken: attacker.pairingToken, + deviceToken: attacker.token, }); const missingScope = await rpcReq(pairingWs, "device.token.rotate", { diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index 4f2f359f54d..c7bb095c98d 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -1,98 +1,32 @@ -import os from "node:os"; -import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - type DeviceIdentity, -} from "../infra/device-identity.js"; import * as devicePairingModule from "../infra/device-pairing.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { getPairedDevice } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + issueOperatorToken, + loadDeviceIdentity, + openTrackedWs, +} from "./device-authz.test-helpers.js"; import { connectOk, connectReq, installGatewayTestHooks, onceMessage, startServerWithClient, - trackConnectChallengeNonce, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); -function resolveDeviceIdentityPath(name: string): string { - const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir(); - return path.join(root, "test-device-identities", `${name}.json`); -} - -function loadDeviceIdentity(name: string): { - identityPath: string; - identity: DeviceIdentity; - publicKey: string; -} { - const identityPath = resolveDeviceIdentityPath(name); - const identity = loadOrCreateDeviceIdentity(identityPath); - return { - identityPath, - identity, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - }; -} - -async function pairReadScopedOperator(name: string): Promise<{ - deviceId: string; - identityPath: string; - deviceToken: string; -}> { - const loaded = loadDeviceIdentity(name); - const request = await requestDevicePairing({ - deviceId: loaded.identity.deviceId, - publicKey: loaded.publicKey, - role: "operator", - scopes: ["operator.read"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - }); - await approveDevicePairing(request.request.requestId); - - const paired = await getPairedDevice(loaded.identity.deviceId); - const deviceToken = paired?.tokens?.operator?.token ?? ""; - expect(deviceToken).toBeTruthy(); - expect(paired?.approvedScopes).toEqual(["operator.read"]); - - return { - deviceId: loaded.identity.deviceId, - identityPath: loaded.identityPath, - deviceToken, - }; -} - -async function openTrackedWs(port: number): Promise { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - trackConnectChallengeNonce(ws); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000); - ws.once("open", () => { - clearTimeout(timer); - resolve(); - }); - ws.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - }); - return ws; -} - describe("gateway silent scope-upgrade reconnect", () => { test("does not silently widen a read-scoped paired device to admin on shared-auth reconnect", async () => { const started = await startServerWithClient("secret"); - const paired = await pairReadScopedOperator("silent-scope-upgrade-reconnect-poc"); + const paired = await issueOperatorToken({ + name: "silent-scope-upgrade-reconnect-poc", + approvedScopes: ["operator.read"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); let watcherWs: WebSocket | undefined; let sharedAuthReconnectWs: WebSocket | undefined; @@ -130,12 +64,12 @@ describe("gateway silent scope-upgrade reconnect", () => { const afterUpgradeAttempt = await getPairedDevice(paired.deviceId); expect(afterUpgradeAttempt?.approvedScopes).toEqual(["operator.read"]); expect(afterUpgradeAttempt?.tokens?.operator?.scopes).toEqual(["operator.read"]); - expect(afterUpgradeAttempt?.tokens?.operator?.token).toBe(paired.deviceToken); + expect(afterUpgradeAttempt?.tokens?.operator?.token).toBe(paired.token); postAttemptDeviceTokenWs = await openTrackedWs(started.port); const afterUpgrade = await connectReq(postAttemptDeviceTokenWs, { skipDefaultAuth: true, - deviceToken: paired.deviceToken, + deviceToken: paired.token, deviceIdentityPath: paired.identityPath, scopes: ["operator.admin"], });