test(gateway): cover event and protocol authorization (#118810)

* test(gateway): cover event and protocol authorization

* test(gateway): match websocket close contract
This commit is contained in:
Vincent Koc
2026-08-04 03:24:18 +08:00
committed by GitHub
parent d6059d20fd
commit cce2d632c9
2 changed files with 223 additions and 0 deletions
@@ -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.
@@ -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<typeof vi.fn>;
sent: Array<{ event?: string }>;
send: ReturnType<typeof vi.fn>;
};
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<GatewayWsClient>([
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();
}
});
});