From 3f2fb7e36589e79408dcfdf93df231eea4850978 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 14:07:29 +0800 Subject: [PATCH] test(qa): cover gateway plugin approvals (#119128) --- .../runtime/gateway-plugin-approvals.yaml | 28 ++ .../gateway-plugin-approvals.e2e.test.ts | 243 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 qa/scenarios/runtime/gateway-plugin-approvals.yaml create mode 100644 test/e2e/qa-lab/runtime/gateway-plugin-approvals.e2e.test.ts diff --git a/qa/scenarios/runtime/gateway-plugin-approvals.yaml b/qa/scenarios/runtime/gateway-plugin-approvals.yaml new file mode 100644 index 000000000000..4e01062e7bfb --- /dev/null +++ b/qa/scenarios/runtime/gateway-plugin-approvals.yaml @@ -0,0 +1,28 @@ +title: Gateway plugin approval lifecycle + +scenario: + id: gateway-plugin-approvals + surface: gateway + category: gateway.approvals-and-remote-execution + coverage: + primary: + - gateway.plugin-approvals + objective: Prove that a real Gateway routes one plugin approval to a capable reviewer and records one consistent terminal decision. + successCriteria: + - The Gateway generates a plugin-prefixed approval ID and reports approval-client delivery. + - A distinct admin reviewer with the approvals capability receives the matching requested event. + - The pending inventory contains the request while waitDecision remains blocked. + - The reviewer resolution produces identical waitDecision and resolved-event outcomes. + - The resolved request leaves the pending approval inventory. + docsRefs: + - docs/gateway/protocol.md + - docs/help/testing.md + codeRefs: + - src/gateway/server-methods/plugin-approval.ts + - src/gateway/server-methods/approval-shared.ts + - src/gateway/server-request-context.ts + - test/e2e/qa-lab/runtime/gateway-plugin-approvals.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/gateway-plugin-approvals.e2e.test.ts + summary: Run authenticated requester and reviewer clients over real Gateway WebSockets through request, wait, resolve, and cleanup. diff --git a/test/e2e/qa-lab/runtime/gateway-plugin-approvals.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-plugin-approvals.e2e.test.ts new file mode 100644 index 000000000000..7f8493b447e6 --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-plugin-approvals.e2e.test.ts @@ -0,0 +1,243 @@ +// Proves the plugin approval lifecycle through authenticated Gateway WebSockets. +import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GATEWAY_CLIENT_CAPS } from "../../../../packages/gateway-protocol/src/client-info.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 { setLoggerOverride } from "../../../../src/logging.js"; + +type Cleanup = () => Promise | void; + +type ApprovalEvent = { + event?: string; + payload?: unknown; +}; + +type ApprovalRecord = { + id: string; + request: { + allowedDecisions?: string[]; + pluginId?: string | null; + }; +}; + +type ApprovalDecision = { + createdAtMs: number; + decision: string; + expiresAtMs: number; + id: string; + terminalReason: string | null; +}; + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`expected ${label}`); + } + return value; +} + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway plugin approvals QA", () => { + const cleanup: Cleanup[] = []; + + afterEach(async () => { + for (const step of cleanup.splice(0).toReversed()) { + await step(); + } + }); + + it("delivers a generated request to a distinct reviewer and resolves one terminal decision", async () => { + let stage = "fixture setup"; + const markStage = (next: string) => { + stage = next; + console.info(`[gateway-plugin-approvals] stage=${stage}`); + }; + + try { + // Keep normal test logs quiet while exposing the existing ws-control + // handshake phase if this real connection fails before hello-ok. + setLoggerOverride({ level: "silent", consoleLevel: "warn", consoleStyle: "compact" }); + + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR is required for gateway QA fixtures"); + } + const reviewerIdentity = loadOrCreateDeviceIdentity({ + path: path.join(stateDir, "test-device-identities", "plugin-approval-reviewer.sqlite"), + }); + const requesterIdentity = loadOrCreateDeviceIdentity({ + path: path.join(stateDir, "test-device-identities", "plugin-approval-requester.sqlite"), + }); + expect(reviewerIdentity.deviceId).not.toBe(requesterIdentity.deviceId); + + markStage("gateway start"); + const port = await getFreePort(); + const token = "gateway-plugin-approvals-qa-token"; + const url = `ws://127.0.0.1:${port}`; + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + sidecarStartup: "defer", + }); + cleanup.push(() => server.close()); + + const approvalEvents: ApprovalEvent[] = []; + markStage("reviewer connect"); + const reviewer = await connectGatewayClient({ + url, + token, + clientDisplayName: "plugin approval reviewer", + scopes: [ADMIN_SCOPE], + caps: [GATEWAY_CLIENT_CAPS.APPROVALS], + deviceIdentity: reviewerIdentity, + onEvent: (event) => { + if ( + event.event === "plugin.approval.requested" || + event.event === "plugin.approval.resolved" + ) { + approvalEvents.push(event); + } + }, + timeoutMs: 60_000, + }); + cleanup.push(() => disconnectGatewayClient(reviewer)); + + markStage("requester connect"); + const requester = await connectGatewayClient({ + url, + token, + clientDisplayName: "plugin approval requester", + scopes: [APPROVALS_SCOPE], + deviceIdentity: requesterIdentity, + timeoutMs: 60_000, + }); + cleanup.push(() => disconnectGatewayClient(requester)); + + markStage("approval request"); + const accepted = await requester.request<{ + deliveryRoute: string; + id: string; + status: string; + }>("plugin.approval.request", { + pluginId: "qa-plugin", + title: "Allow fixture mutation", + description: "The QA fixture requests one bounded mutation.", + allowedDecisions: ["allow-once"], + twoPhase: true, + timeoutMs: 30_000, + }); + expect(accepted).toMatchObject({ + status: "accepted", + deliveryRoute: "approval-client", + }); + expect(accepted.id).toMatch(/^plugin:[0-9a-f-]{36}$/); + + markStage("requested event"); + await vi.waitFor(() => { + expect( + approvalEvents.filter((event) => event.event === "plugin.approval.requested"), + ).toHaveLength(1); + }); + const requestedEvent = approvalEvents.find( + (event) => event.event === "plugin.approval.requested", + ); + expect( + requireRecord(requestedEvent?.payload, "plugin approval requested event"), + ).toMatchObject({ + id: accepted.id, + request: { + pluginId: "qa-plugin", + allowedDecisions: ["allow-once", "deny"], + }, + }); + + markStage("pending inventory"); + const pending = await reviewer.request("plugin.approval.list", {}); + expect(pending).toEqual([ + expect.objectContaining({ + id: accepted.id, + request: expect.objectContaining({ + pluginId: "qa-plugin", + allowedDecisions: ["allow-once", "deny"], + }), + }), + ]); + + markStage("wait decision pending"); + let waitSettled = false; + const waitDecision = requester + .request( + "plugin.approval.waitDecision", + { id: accepted.id }, + { timeoutMs: 10_000 }, + ) + .finally(() => { + waitSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(waitSettled).toBe(false); + + markStage("reviewer resolve"); + await expect( + reviewer.request("plugin.approval.resolve", { + id: accepted.id, + decision: "allow-once", + }), + ).resolves.toEqual({ ok: true }); + + markStage("terminal decision"); + const terminalDecision = await waitDecision; + await vi.waitFor(() => { + expect( + approvalEvents.filter((event) => event.event === "plugin.approval.resolved"), + ).toHaveLength(1); + }); + const resolvedEvent = approvalEvents.find( + (event) => event.event === "plugin.approval.resolved", + ); + const resolvedPayload = requireRecord( + resolvedEvent?.payload, + "plugin approval resolved event", + ); + const waitTerminalDecision = { + id: terminalDecision.id, + decision: terminalDecision.decision, + }; + const eventTerminalDecision = { + id: resolvedPayload.id, + decision: resolvedPayload.decision, + }; + expect(waitTerminalDecision).toEqual({ + id: accepted.id, + decision: "allow-once", + }); + expect(eventTerminalDecision).toEqual(waitTerminalDecision); + expect(terminalDecision.createdAtMs).toEqual(expect.any(Number)); + expect(terminalDecision.expiresAtMs).toEqual(expect.any(Number)); + expect(terminalDecision.expiresAtMs).toBeGreaterThan(terminalDecision.createdAtMs); + expect(terminalDecision.terminalReason).toBe("user"); + + markStage("pending inventory empty"); + await expect(reviewer.request("plugin.approval.list", {})).resolves.toEqual( + [], + ); + } catch (error) { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + throw new Error(`[gateway-plugin-approvals] failed stage=${stage}: ${detail}`, { + cause: error, + }); + } + }, 120_000); +});