From 16635fa9c74aa8501349ec87f24e65e455f22800 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 05:23:28 +0800 Subject: [PATCH] test(qa): cover gateway exec approvals (#118872) --- .../runtime/gateway-exec-approvals.yaml | 27 +++ .../gateway-exec-approvals.e2e.test.ts | 196 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 qa/scenarios/runtime/gateway-exec-approvals.yaml create mode 100644 test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts diff --git a/qa/scenarios/runtime/gateway-exec-approvals.yaml b/qa/scenarios/runtime/gateway-exec-approvals.yaml new file mode 100644 index 000000000000..56536d906674 --- /dev/null +++ b/qa/scenarios/runtime/gateway-exec-approvals.yaml @@ -0,0 +1,27 @@ +title: Gateway exec approvals + +scenario: + id: gateway-exec-approvals + surface: gateway + category: gateway.approvals-and-remote-execution + coverage: + primary: + - gateway.exec-approvals + objective: Verify Gateway exec approval policy snapshots and pending approval decisions through authenticated WebSocket clients. + successCriteria: + - The Gateway returns a redacted persisted approval policy snapshot and its authoritative hash. + - A hash-guarded replacement succeeds while a stale replacement is rejected without changing stored policy. + - A two-phase exec approval request is accepted and visible through list and lookup RPCs. + - A distinct authenticated reviewer resolves a concurrent waitDecision request. + - The resolved request leaves the pending approval inventory. + docsRefs: + - docs/gateway/protocol.md + - docs/help/testing.md + codeRefs: + - src/gateway/server-methods/exec-approvals.ts + - src/gateway/server-methods/exec-approval.ts + - test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts + summary: Run a real Gateway with authenticated requester and reviewer WebSocket clients across snapshot mutation and approval decision APIs. diff --git a/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts new file mode 100644 index 000000000000..fe5838f0e2dc --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts @@ -0,0 +1,196 @@ +// Gateway exec approvals QA proves policy snapshots and approval decisions over real WebSockets. +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ExecApprovalsSnapshot } from "../../../../packages/gateway-protocol/src/index.js"; +import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../../../src/gateway/method-scopes.js"; +import { + connectGatewayClient, + disconnectGatewayClient, +} from "../../../../src/gateway/test-helpers.e2e.js"; +import { + getFreePort, + installGatewayTestHooks, + startGatewayServer, +} from "../../../../src/gateway/test-helpers.js"; +import { loadOrCreateDeviceIdentity } from "../../../../src/infra/device-identity.js"; +import { readExecApprovalsSnapshot } from "../../../../src/infra/exec-approvals.js"; + +type Cleanup = () => Promise | void; + +type ExecApprovalListEntry = { + id: string; + request: { + command?: string; + }; +}; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway exec approvals QA", () => { + const cleanup: Cleanup[] = []; + + afterEach(async () => { + for (const step of cleanup.splice(0).toReversed()) { + await step(); + } + }); + + it("protects policy snapshots and resolves a pending request from another reviewer", async () => { + const port = await getFreePort(); + const token = "gateway-exec-approvals-qa-token"; + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR is required for gateway QA fixtures"); + } + + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + sidecarStartup: "defer", + }); + cleanup.push(() => server.close()); + + const requesterIdentity = loadOrCreateDeviceIdentity({ + path: path.join(stateDir, "test-device-identities", "exec-requester.sqlite"), + }); + const reviewerIdentity = loadOrCreateDeviceIdentity({ + path: path.join(stateDir, "test-device-identities", "exec-reviewer.sqlite"), + }); + expect(reviewerIdentity.deviceId).not.toBe(requesterIdentity.deviceId); + + const url = `ws://127.0.0.1:${port}`; + const requester = await connectGatewayClient({ + url, + token, + clientDisplayName: "exec approval requester", + deviceIdentity: requesterIdentity, + scopes: [APPROVALS_SCOPE], + timeoutMs: 60_000, + }); + cleanup.push(() => disconnectGatewayClient(requester)); + + const reviewer = await connectGatewayClient({ + url, + token, + clientDisplayName: "exec approval reviewer", + deviceIdentity: reviewerIdentity, + scopes: [ADMIN_SCOPE], + timeoutMs: 60_000, + }); + cleanup.push(() => disconnectGatewayClient(reviewer)); + + const initial = await reviewer.request("exec.approvals.get", {}); + const storedInitial = readExecApprovalsSnapshot(); + expect(storedInitial.file.socket?.token).toMatch(/^[A-Za-z0-9_-]{32}$/); + expect(initial).toMatchObject({ + exists: true, + hash: storedInitial.hash, + file: { + version: 1, + socket: { path: storedInitial.file.socket?.path }, + }, + }); + expect(initial.file.socket).not.toHaveProperty("token"); + + const replacement = { + version: 1 as const, + defaults: { + security: "allowlist", + ask: "always", + askFallback: "deny", + autoAllowSkills: false, + }, + agents: { + main: { + allowlist: [{ pattern: "printf gateway-qa" }], + }, + }, + }; + const updated = await reviewer.request("exec.approvals.set", { + baseHash: initial.hash, + file: replacement, + }); + expect(updated.hash).not.toBe(initial.hash); + expect(updated.file).toMatchObject({ + ...replacement, + socket: { path: storedInitial.file.socket?.path }, + }); + expect(updated.file.socket).not.toHaveProperty("token"); + expect(readExecApprovalsSnapshot().file.socket?.token).toBe(storedInitial.file.socket?.token); + + await expect( + reviewer.request("exec.approvals.set", { + baseHash: initial.hash, + file: { + ...replacement, + defaults: { ...replacement.defaults, security: "deny" }, + }, + }), + ).rejects.toThrow("exec approvals changed since last load"); + await expect( + reviewer.request("exec.approvals.get", {}), + ).resolves.toEqual(updated); + + const approvalId = "gateway-exec-approvals-qa"; + await expect( + requester.request("exec.approval.request", { + id: approvalId, + command: "printf gateway-qa", + commandArgv: ["printf", "gateway-qa"], + cwd: "/tmp", + host: "gateway", + ask: "always", + twoPhase: true, + requireDeliveryRoute: false, + timeoutMs: 60_000, + }), + ).resolves.toMatchObject({ id: approvalId, status: "accepted" }); + + const pending = await requester.request("exec.approval.list", {}); + expect(pending).toContainEqual( + expect.objectContaining({ + id: approvalId, + request: expect.objectContaining({ command: "printf gateway-qa" }), + }), + ); + await expect(requester.request("exec.approval.get", { id: approvalId })).resolves.toMatchObject( + { + id: approvalId, + commandText: "printf gateway-qa", + host: "gateway", + nodeId: null, + agentId: null, + }, + ); + + let waitSettled = false; + const waitDecision = requester + .request<{ + id: string; + decision: string; + }>("exec.approval.waitDecision", { id: approvalId }, { timeoutMs: 10_000 }) + .finally(() => { + waitSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(waitSettled).toBe(false); + + await expect( + reviewer.request("exec.approval.resolve", { + id: approvalId, + decision: "allow-once", + }), + ).resolves.toEqual({ ok: true }); + await expect(waitDecision).resolves.toMatchObject({ + id: approvalId, + decision: "allow-once", + }); + + const remaining = await requester.request("exec.approval.list", {}); + expect(remaining.map((entry) => entry.id)).not.toContain(approvalId); + await expect(requester.request("exec.approval.get", { id: approvalId })).rejects.toThrow( + "unknown or expired approval id", + ); + }, 120_000); +});