test(qa): cover gateway plugin approvals (#119128)

This commit is contained in:
Vincent Koc
2026-08-04 14:07:29 +08:00
committed by GitHub
parent 01e7fca716
commit 3f2fb7e365
2 changed files with 271 additions and 0 deletions
@@ -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.
@@ -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> | 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<string, unknown> {
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<ApprovalRecord[]>("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<ApprovalDecision>(
"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<ApprovalRecord[]>("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);
});