From 7ba4df67ad4b700b651cd23d2f44df0e708f08bb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 03:10:43 +0800 Subject: [PATCH] test(gateway): split node pairing diagnostics suite --- src/gateway/server.node-pairing-authz.test.ts | 208 --------------- ...ver.node-pairing-diagnostics-authz.test.ts | 236 ++++++++++++++++++ 2 files changed, 236 insertions(+), 208 deletions(-) create mode 100644 src/gateway/server.node-pairing-diagnostics-authz.test.ts diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index 3b4f1721941d..662bf9f76308 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -12,7 +12,6 @@ import { GATEWAY_CLIENT_NAMES, type GatewayClientName, } from "../utils/message-channel.js"; -import { callGateway } from "./call.js"; import { issueOperatorToken, loadDeviceIdentity, @@ -718,213 +717,6 @@ describe("gateway node pairing authorization", () => { }); }); - describeWithGatewayServer("pending diagnostics scopes", (getStarted) => { - test("shows pending pairing records to direct-local backend shared-auth callers", async () => { - const pendingOnlyNodeId = "node-local-backend-pending"; - await seedNodeDevice(pendingOnlyNodeId); - const pending = await requestNodePairing({ - nodeId: pendingOnlyNodeId, - platform: "macos", - commands: ["system.run"], - }); - - const listed = await callGateway<{ - nodes?: Array<{ - nodeId: string; - approvalState?: string; - pendingRequestId?: string; - }>; - }>({ - config: { - gateway: { - mode: "local", - bind: "loopback", - port: getStarted().port, - auth: { mode: "token", token: "secret" }, - }, - }, - method: "node.list", - scopes: ["operator.read", "operator.pairing"], - requireLocalBackendSharedAuth: true, - timeoutMs: 2_000, - }); - - expect(listed.nodes).toContainEqual( - expect.objectContaining({ - nodeId: pendingOnlyNodeId, - approvalState: "pending-approval", - pendingRequestId: pending.request.requestId, - }), - ); - }); - - test("shows only the caller's pending request id to read-only callers", async () => { - const pairedNodeId = "node-read-only-paired"; - const pendingOnlyNodeId = "node-read-only-pending"; - const visiblePendingNode = await pairDeviceIdentity({ - name: "node-read-only-visible-pending", - role: "operator", - scopes: ["operator.read"], - }); - await pairDeviceIdentity({ - name: "node-read-only-visible-pending", - role: "node", - scopes: [], - clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, - clientMode: GATEWAY_CLIENT_MODES.NODE, - }); - await seedNodeDevice(pairedNodeId); - const initial = await requestNodePairing({ - nodeId: pairedNodeId, - platform: "macos", - commands: ["screen.snapshot"], - }); - await approveNodePairing(initial.request.requestId, { - callerScopes: ["operator.pairing", "operator.write"], - }); - await requestNodePairing({ - nodeId: pairedNodeId, - platform: "macos", - commands: ["screen.snapshot", "system.run"], - }); - await seedNodeDevice(pendingOnlyNodeId); - await requestNodePairing({ - nodeId: pendingOnlyNodeId, - platform: "macos", - commands: ["system.run"], - }); - const visiblePending = await requestNodePairing({ - nodeId: visiblePendingNode.identity.deviceId, - platform: "android", - commands: ["device.status"], - }); - - const ws = await openTrackedWs(getStarted().port); - try { - await connectOk(ws, { - token: "secret", - scopes: ["operator.read"], - deviceIdentityPath: `${await makeNodePairingStateDir()}/read-only.sqlite`, - }); - - type NodeDiagnostics = { - nodeId: string; - approvalState?: string; - pendingRequestId?: string; - pendingDeclaredCommands?: string[]; - }; - const listed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(ws, "node.list", {}); - expect(listed.ok).toBe(true); - const nodes = listed.payload?.nodes ?? []; - // Pending surfaces now attach to paired devices, so the row is visible - // to read-only callers but its approval target stays redacted. - expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).toEqual( - expect.objectContaining({ - nodeId: pendingOnlyNodeId, - approvalState: "pending-approval", - }), - ); - expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).not.toHaveProperty( - "pendingRequestId", - ); - expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).not.toHaveProperty( - "pendingDeclaredCommands", - ); - expect(nodes.find((node) => node.nodeId === pairedNodeId)).toEqual( - expect.objectContaining({ - nodeId: pairedNodeId, - approvalState: "pending-reapproval", - }), - ); - expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty( - "pendingRequestId", - ); - expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty( - "pendingDeclaredCommands", - ); - expect(nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId)).toEqual( - expect.objectContaining({ - nodeId: visiblePendingNode.identity.deviceId, - approvalState: "pending-approval", - }), - ); - expect( - nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId), - ).not.toHaveProperty("pendingRequestId"); - expect( - nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId), - ).not.toHaveProperty("pendingDeclaredCommands"); - - const described = await rpcReq(ws, "node.describe", { - nodeId: pairedNodeId, - }); - expect(described.payload).toEqual( - expect.objectContaining({ - nodeId: pairedNodeId, - approvalState: "pending-reapproval", - }), - ); - expect(described.payload).not.toHaveProperty("pendingRequestId"); - expect(described.payload).not.toHaveProperty("pendingDeclaredCommands"); - - const describedVisiblePending = await rpcReq(ws, "node.describe", { - nodeId: visiblePendingNode.identity.deviceId, - }); - expect(describedVisiblePending.payload).toEqual( - expect.objectContaining({ - nodeId: visiblePendingNode.identity.deviceId, - approvalState: "pending-approval", - }), - ); - expect(describedVisiblePending.payload).not.toHaveProperty("pendingRequestId"); - expect(describedVisiblePending.payload).not.toHaveProperty("pendingDeclaredCommands"); - - const pendingOnly = await rpcReq(ws, "node.describe", { - nodeId: pendingOnlyNodeId, - }); - expect(pendingOnly.ok).toBe(true); - expect(pendingOnly.payload).not.toHaveProperty("pendingRequestId"); - expect(pendingOnly.payload).not.toHaveProperty("pendingDeclaredCommands"); - - const selfWs = await openTrackedWs(getStarted().port); - try { - await connectOk(selfWs, { - token: "secret", - scopes: ["operator.read"], - deviceIdentityPath: visiblePendingNode.identityPath, - }); - const selfListed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(selfWs, "node.list", {}); - const selfNodes = selfListed.payload?.nodes ?? []; - expect( - selfNodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId), - ).toEqual( - expect.objectContaining({ - approvalState: "pending-approval", - pendingRequestId: visiblePending.request.requestId, - }), - ); - expect(selfNodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty( - "pendingRequestId", - ); - - const selfDescribed = await rpcReq(selfWs, "node.describe", { - nodeId: visiblePendingNode.identity.deviceId, - }); - expect(selfDescribed.payload).toEqual( - expect.objectContaining({ - approvalState: "pending-approval", - pendingRequestId: visiblePending.request.requestId, - }), - ); - } finally { - selfWs.close(); - } - } finally { - ws.close(); - } - }); - }); - describeWithGatewayServer("paired node reconnects", (getStarted) => { test("withholds plugin surface URLs until the node capability is approved", async () => { // The shared Gateway harness disables Canvas startup; expose its descriptor diff --git a/src/gateway/server.node-pairing-diagnostics-authz.test.ts b/src/gateway/server.node-pairing-diagnostics-authz.test.ts new file mode 100644 index 000000000000..3b706ce48746 --- /dev/null +++ b/src/gateway/server.node-pairing-diagnostics-authz.test.ts @@ -0,0 +1,236 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { approveNodePairing, requestNodePairing } from "../infra/node-pairing.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { callGateway } from "./call.js"; +import { openTrackedWs, pairDeviceIdentity } from "./device-authz.test-helpers.js"; +import { + createNodePairingTestState, + describeWithGatewayServer, +} from "./server.node-pairing.test-support.js"; +import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +const { + cleanup: cleanupNodePairingTestState, + makeStateDir: makeNodePairingStateDir, + seedNodeDevice, + setup: setupNodePairingTestState, +} = createNodePairingTestState("openclaw-node-pair-authz-diagnostics-"); + +describe("gateway node pairing authorization", () => { + beforeAll(async () => { + await setupNodePairingTestState(); + }); + + afterAll(async () => { + await cleanupNodePairingTestState(); + }); + + describeWithGatewayServer("pending diagnostics scopes", (getStarted) => { + test("shows pending pairing records to direct-local backend shared-auth callers", async () => { + const pendingOnlyNodeId = "node-local-backend-pending"; + await seedNodeDevice(pendingOnlyNodeId); + const pending = await requestNodePairing({ + nodeId: pendingOnlyNodeId, + platform: "macos", + commands: ["system.run"], + }); + + const listed = await callGateway<{ + nodes?: Array<{ + nodeId: string; + approvalState?: string; + pendingRequestId?: string; + }>; + }>({ + config: { + gateway: { + mode: "local", + bind: "loopback", + port: getStarted().port, + auth: { mode: "token", token: "secret" }, + }, + }, + method: "node.list", + scopes: ["operator.read", "operator.pairing"], + requireLocalBackendSharedAuth: true, + timeoutMs: 2_000, + }); + + expect(listed.nodes).toContainEqual( + expect.objectContaining({ + nodeId: pendingOnlyNodeId, + approvalState: "pending-approval", + pendingRequestId: pending.request.requestId, + }), + ); + }); + + test("shows only the caller's pending request id to read-only callers", async () => { + const pairedNodeId = "node-read-only-paired"; + const pendingOnlyNodeId = "node-read-only-pending"; + const visiblePendingNode = await pairDeviceIdentity({ + name: "node-read-only-visible-pending", + role: "operator", + scopes: ["operator.read"], + }); + await pairDeviceIdentity({ + name: "node-read-only-visible-pending", + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientMode: GATEWAY_CLIENT_MODES.NODE, + }); + await seedNodeDevice(pairedNodeId); + const initial = await requestNodePairing({ + nodeId: pairedNodeId, + platform: "macos", + commands: ["screen.snapshot"], + }); + await approveNodePairing(initial.request.requestId, { + callerScopes: ["operator.pairing", "operator.write"], + }); + await requestNodePairing({ + nodeId: pairedNodeId, + platform: "macos", + commands: ["screen.snapshot", "system.run"], + }); + await seedNodeDevice(pendingOnlyNodeId); + await requestNodePairing({ + nodeId: pendingOnlyNodeId, + platform: "macos", + commands: ["system.run"], + }); + const visiblePending = await requestNodePairing({ + nodeId: visiblePendingNode.identity.deviceId, + platform: "android", + commands: ["device.status"], + }); + + const ws = await openTrackedWs(getStarted().port); + try { + await connectOk(ws, { + token: "secret", + scopes: ["operator.read"], + deviceIdentityPath: `${await makeNodePairingStateDir()}/read-only.sqlite`, + }); + + type NodeDiagnostics = { + nodeId: string; + approvalState?: string; + pendingRequestId?: string; + pendingDeclaredCommands?: string[]; + }; + const listed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(ws, "node.list", {}); + expect(listed.ok).toBe(true); + const nodes = listed.payload?.nodes ?? []; + // Pending surfaces now attach to paired devices, so the row is visible + // to read-only callers but its approval target stays redacted. + expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).toEqual( + expect.objectContaining({ + nodeId: pendingOnlyNodeId, + approvalState: "pending-approval", + }), + ); + expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).not.toHaveProperty( + "pendingRequestId", + ); + expect(nodes.find((node) => node.nodeId === pendingOnlyNodeId)).not.toHaveProperty( + "pendingDeclaredCommands", + ); + expect(nodes.find((node) => node.nodeId === pairedNodeId)).toEqual( + expect.objectContaining({ + nodeId: pairedNodeId, + approvalState: "pending-reapproval", + }), + ); + expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty( + "pendingRequestId", + ); + expect(nodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty( + "pendingDeclaredCommands", + ); + expect(nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId)).toEqual( + expect.objectContaining({ + nodeId: visiblePendingNode.identity.deviceId, + approvalState: "pending-approval", + }), + ); + expect( + nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId), + ).not.toHaveProperty("pendingRequestId"); + expect( + nodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId), + ).not.toHaveProperty("pendingDeclaredCommands"); + + const described = await rpcReq(ws, "node.describe", { + nodeId: pairedNodeId, + }); + expect(described.payload).toEqual( + expect.objectContaining({ + nodeId: pairedNodeId, + approvalState: "pending-reapproval", + }), + ); + expect(described.payload).not.toHaveProperty("pendingRequestId"); + expect(described.payload).not.toHaveProperty("pendingDeclaredCommands"); + + const describedVisiblePending = await rpcReq(ws, "node.describe", { + nodeId: visiblePendingNode.identity.deviceId, + }); + expect(describedVisiblePending.payload).toEqual( + expect.objectContaining({ + nodeId: visiblePendingNode.identity.deviceId, + approvalState: "pending-approval", + }), + ); + expect(describedVisiblePending.payload).not.toHaveProperty("pendingRequestId"); + expect(describedVisiblePending.payload).not.toHaveProperty("pendingDeclaredCommands"); + + const pendingOnly = await rpcReq(ws, "node.describe", { + nodeId: pendingOnlyNodeId, + }); + expect(pendingOnly.ok).toBe(true); + expect(pendingOnly.payload).not.toHaveProperty("pendingRequestId"); + expect(pendingOnly.payload).not.toHaveProperty("pendingDeclaredCommands"); + + const selfWs = await openTrackedWs(getStarted().port); + try { + await connectOk(selfWs, { + token: "secret", + scopes: ["operator.read"], + deviceIdentityPath: visiblePendingNode.identityPath, + }); + const selfListed = await rpcReq<{ nodes?: NodeDiagnostics[] }>(selfWs, "node.list", {}); + const selfNodes = selfListed.payload?.nodes ?? []; + expect( + selfNodes.find((node) => node.nodeId === visiblePendingNode.identity.deviceId), + ).toEqual( + expect.objectContaining({ + approvalState: "pending-approval", + pendingRequestId: visiblePending.request.requestId, + }), + ); + expect(selfNodes.find((node) => node.nodeId === pairedNodeId)).not.toHaveProperty( + "pendingRequestId", + ); + + const selfDescribed = await rpcReq(selfWs, "node.describe", { + nodeId: visiblePendingNode.identity.deviceId, + }); + expect(selfDescribed.payload).toEqual( + expect.objectContaining({ + approvalState: "pending-approval", + pendingRequestId: visiblePending.request.requestId, + }), + ); + } finally { + selfWs.close(); + } + } finally { + ws.close(); + } + }); + }); +});