From cce2d632c9cc264e409d69779e20f94d9db7aa45 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 03:24:18 +0800 Subject: [PATCH] test(gateway): cover event and protocol authorization (#118810) * test(gateway): cover event and protocol authorization * test(gateway): match websocket close contract --- .../runtime/gateway-event-protocol-authz.yaml | 27 +++ .../gateway-event-protocol-authz.e2e.test.ts | 196 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 qa/scenarios/runtime/gateway-event-protocol-authz.yaml create mode 100644 test/e2e/qa-lab/runtime/gateway-event-protocol-authz.e2e.test.ts diff --git a/qa/scenarios/runtime/gateway-event-protocol-authz.yaml b/qa/scenarios/runtime/gateway-event-protocol-authz.yaml new file mode 100644 index 000000000000..3939a22b69df --- /dev/null +++ b/qa/scenarios/runtime/gateway-event-protocol-authz.yaml @@ -0,0 +1,27 @@ +title: Gateway event and protocol authorization evidence + +scenario: + id: gateway-event-protocol-authz + surface: gateway + coverage: + primary: + - gateway.event-scoping + - gateway.fail-closed-protocol-handling + objective: Prove Gateway event authorization and fail-closed pre-auth protocol handling at the production broadcaster and real WebSocket boundaries. + successCriteria: + - Core events reach only operator scopes authorized by the production broadcaster, while node and pairing clients remain excluded. + - Unknown core events are dropped, default plugin events require write access, and explicit plugin read and write scopes select the correct recipients. + - Invalid, reserved, and unsupported plugin event declarations are rejected before broadcast. + - A real Gateway closes malformed request frames and non-connect first requests with policy code 1008. + - A real Gateway closes oversized pre-auth frames with code 1009 and emits bounded diagnostics without retaining payload content. + docsRefs: + - docs/gateway/protocol.md + - docs/gateway/security/index.md + codeRefs: + - src/gateway/server-broadcast.ts + - src/gateway/server/ws-connection/message-handler.ts + - test/e2e/qa-lab/runtime/gateway-event-protocol-authz.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/gateway-event-protocol-authz.e2e.test.ts + summary: Run production broadcaster authorization and real Gateway pre-auth fail-closed assertions. diff --git a/test/e2e/qa-lab/runtime/gateway-event-protocol-authz.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-event-protocol-authz.e2e.test.ts new file mode 100644 index 000000000000..73841eb17980 --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-event-protocol-authz.e2e.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import { createGatewayBroadcaster } from "../../../../src/gateway/server-broadcast.js"; +import { MAX_PREAUTH_PAYLOAD_BYTES } from "../../../../src/gateway/server-constants.js"; +import type { GatewayWsClient } from "../../../../src/gateway/server/ws-types.js"; +import { + createGatewaySuiteHarness, + installGatewayTestHooks, + onceMessage, +} from "../../../../src/gateway/test-helpers.js"; +import { + onDiagnosticEvent, + resetDiagnosticEventsForTest, + type DiagnosticEventPayload, +} from "../../../../src/infra/diagnostic-events.js"; + +installGatewayTestHooks({ scope: "suite" }); + +type RecordingSocket = { + bufferedAmount: number; + close: ReturnType; + sent: Array<{ event?: string }>; + send: ReturnType; +}; + +function makeRecordingSocket(): RecordingSocket { + const sent: Array<{ event?: string }> = []; + return { + bufferedAmount: 0, + close: vi.fn(), + sent, + send: vi.fn((raw: string) => { + sent.push(JSON.parse(raw) as { event?: string }); + }), + }; +} + +function makeClient( + connId: string, + socket: RecordingSocket, + role: "node" | "operator", + scopes: string[], +): GatewayWsClient { + return { + connId, + connect: { role, scopes } as GatewayWsClient["connect"], + socket: socket as unknown as GatewayWsClient["socket"], + usesSharedGatewayAuth: false, + }; +} + +function sentEvents(socket: RecordingSocket): string[] { + return socket.sent.flatMap((frame) => (frame.event ? [frame.event] : [])); +} + +function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> { + return new Promise((resolve) => { + ws.once("close", (code, reason) => { + resolve({ code, reason: reason.toString() }); + }); + }); +} + +afterEach(() => { + resetDiagnosticEventsForTest(); +}); + +describe("Gateway event and protocol authorization", () => { + it("applies the production event recipient matrix and rejects unsafe plugin broadcasts", () => { + const pairingSocket = makeRecordingSocket(); + const nodeSocket = makeRecordingSocket(); + const readSocket = makeRecordingSocket(); + const writeSocket = makeRecordingSocket(); + const adminSocket = makeRecordingSocket(); + const clients = new Set([ + makeClient("pairing", pairingSocket, "operator", ["operator.pairing"]), + makeClient("node", nodeSocket, "node", ["operator.read"]), + makeClient("read", readSocket, "operator", ["operator.read"]), + makeClient("write", writeSocket, "operator", ["operator.write"]), + makeClient("admin", adminSocket, "operator", ["operator.admin"]), + ]); + const { broadcast, broadcastPluginEvent } = createGatewayBroadcaster({ clients }); + + broadcast("chat", { sessionKey: "agent:main:main", message: "scoped" }); + broadcast("unknown.future.event", { hidden: true }); + broadcast("plugin.example.default", { revision: 1 }); + broadcastPluginEvent("plugin.example.read", { revision: 2 }, "operator.read"); + broadcastPluginEvent("plugin.example.write", { revision: 3 }, "operator.write"); + + expect(sentEvents(pairingSocket)).toEqual([]); + expect(sentEvents(nodeSocket)).toEqual([]); + expect(sentEvents(readSocket)).toEqual(["chat", "plugin.example.read"]); + expect(sentEvents(writeSocket)).toEqual([ + "chat", + "plugin.example.default", + "plugin.example.read", + "plugin.example.write", + ]); + expect(sentEvents(adminSocket)).toEqual([ + "chat", + "plugin.example.default", + "plugin.example.read", + "plugin.example.write", + ]); + + const unsafe = broadcastPluginEvent as unknown as ( + event: string, + payload: unknown, + scope: string, + ) => void; + expect(() => unsafe("example.invalid", {}, "operator.read")).toThrow( + "invalid plugin gateway event", + ); + expect(() => unsafe("plugin.approval.requested", {}, "operator.read")).toThrow( + "invalid plugin gateway event", + ); + expect(() => unsafe("plugin.example.invalid", {}, "operator.approvals")).toThrow( + "invalid plugin gateway event scope", + ); + }); + + it.each([ + ["malformed request frame", JSON.stringify({ unexpected: true }), "invalid request frame"], + [ + "non-connect first request", + JSON.stringify({ type: "req", id: "health-before-connect", method: "health" }), + "first request must be connect", + ], + ])("closes a %s with policy code 1008", async (_name, frame, expectedReason) => { + const harness = await createGatewaySuiteHarness({ + serverOptions: { auth: { mode: "none" } }, + }); + try { + const ws = await harness.openWs(); + const closed = waitForClose(ws); + const response = frame.includes("health-before-connect") + ? onceMessage(ws, (message) => message.id === "health-before-connect") + : undefined; + + ws.send(frame); + + if (response) { + await expect(response).resolves.toMatchObject({ + type: "res", + id: "health-before-connect", + ok: false, + }); + } + await expect(closed).resolves.toMatchObject({ + code: 1008, + reason: expect.stringContaining(expectedReason), + }); + } finally { + await harness.close(); + } + }); + + it("closes oversized pre-auth frames with 1009 and emits only redacted diagnostics", async () => { + resetDiagnosticEventsForTest(); + const events: DiagnosticEventPayload[] = []; + const stopDiagnostics = onDiagnosticEvent((event) => events.push(event)); + const harness = await createGatewaySuiteHarness({ + serverOptions: { auth: { mode: "none" } }, + }); + const secretMarker = "sensitive-preauth-marker"; + try { + const ws = await harness.openWs(); + const closed = waitForClose(ws); + ws.send( + JSON.stringify({ + type: "req", + id: "oversized-connect", + method: "connect", + params: { + pathEnv: `${secretMarker}${"x".repeat(MAX_PREAUTH_PAYLOAD_BYTES + 1024)}`, + }, + }), + ); + + await expect(closed).resolves.toMatchObject({ code: 1009 }); + const event = events.find((candidate) => candidate.type === "payload.large"); + expect(event).toMatchObject({ + type: "payload.large", + action: "rejected", + surface: "gateway.ws.preauth", + limitBytes: MAX_PREAUTH_PAYLOAD_BYTES, + reason: "preauth_frame_limit", + }); + expect(JSON.stringify(event)).not.toContain(secretMarker); + expect(event).not.toHaveProperty("payload"); + } finally { + stopDiagnostics(); + await harness.close(); + } + }); +});