refactor: share gateway misc test helpers

This commit is contained in:
Vincent Koc
2026-06-02 06:08:10 +02:00
parent 6349af6502
commit e6b5083660

View File

@@ -117,10 +117,36 @@ describe("GatewayClient", () => {
);
}
test("uses a large maxPayload for node snapshots", () => {
const client = new GatewayClient({ url: "ws://127.0.0.1:1" });
function startGatewayClient(params: { url: string; tlsFingerprint?: string }) {
const client = new GatewayClient(params);
client.start();
const last = wsMockState.last as { url: unknown; opts: unknown } | null;
return wsMockState.last;
}
function expectNoGatewayClientAgent(params: { url: string; tlsFingerprint?: string }) {
const last = startGatewayClient(params) as { opts: { agent?: unknown } } | null;
expect(last?.opts.agent).toBeUndefined();
}
function setCorporateNoProxy() {
process.env["NO_PROXY"] = "corp.example.com";
process.env["no_proxy"] = "corp.example.com";
}
function setGatewayOnlyProxyEnv() {
process.env["HTTP_PROXY"] = "http://127.0.0.1:3128";
process.env["HTTPS_PROXY"] = "http://127.0.0.1:3128";
}
function registerGatewayOnlyProxy() {
return registerActiveManagedProxyUrl(new URL("http://127.0.0.1:3128"), "gateway-only");
}
test("uses a large maxPayload for node snapshots", () => {
const last = startGatewayClient({ url: "ws://127.0.0.1:1" }) as {
url: unknown;
opts: unknown;
} | null;
const opts = last?.opts as { maxPayload?: number } | undefined;
expect(last?.url).toBe("ws://127.0.0.1:1");
@@ -128,52 +154,32 @@ describe("GatewayClient", () => {
});
test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => {
const client = new GatewayClient({ url: "ws://127.0.0.1:1" });
client.start();
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
expect(last?.opts.agent).toBeUndefined();
expectNoGatewayClientAgent({ url: "ws://127.0.0.1:1" });
});
test("does not pass an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => {
const client = new GatewayClient({ url: "ws://[::1]:1" });
client.start();
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
expect(last?.opts.agent).toBeUndefined();
expectNoGatewayClientAgent({ url: "ws://[::1]:1" });
});
test("does not pass an explicit direct agent for localhost hostnames", () => {
const client = new GatewayClient({ url: "ws://localhost:1" });
client.start();
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
expect(last?.opts.agent).toBeUndefined();
expectNoGatewayClientAgent({ url: "ws://localhost:1" });
});
test("does not force a direct agent for remote Gateway WebSocket connections", () => {
const client = new GatewayClient({
expectNoGatewayClientAgent({
url: "wss://gateway.example.com",
tlsFingerprint: "SHA256:AA:BB",
});
client.start();
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
expect(last?.opts.agent).toBeUndefined();
});
test("scopes Gateway loopback bypass to WebSocket connection setup without mutating NO_PROXY", () => {
process.env["NO_PROXY"] = "corp.example.com";
process.env["no_proxy"] = "corp.example.com";
const registration = registerActiveManagedProxyUrl(
new URL("http://127.0.0.1:3128"),
"gateway-only",
);
setCorporateNoProxy();
const registration = registerGatewayOnlyProxy();
try {
const client = new GatewayClient({ url: "ws://127.0.0.1:18789" });
client.start();
const last = wsMockState.last as { noProxyDuringConstruction: unknown } | null;
const last = startGatewayClient({ url: "ws://127.0.0.1:18789" }) as {
noProxyDuringConstruction: unknown;
} | null;
expect(last?.noProxyDuringConstruction).toBe("corp.example.com");
expect(process.env["NO_PROXY"]).toBe("corp.example.com");
@@ -184,19 +190,12 @@ describe("GatewayClient", () => {
});
test("scopes IPv6 loopback bypass during Gateway-only proxy mode connection setup", () => {
process.env["NO_PROXY"] = "corp.example.com";
process.env["no_proxy"] = "corp.example.com";
process.env["HTTP_PROXY"] = "http://127.0.0.1:3128";
process.env["HTTPS_PROXY"] = "http://127.0.0.1:3128";
const registration = registerActiveManagedProxyUrl(
new URL("http://127.0.0.1:3128"),
"gateway-only",
);
setCorporateNoProxy();
setGatewayOnlyProxyEnv();
const registration = registerGatewayOnlyProxy();
try {
const client = new GatewayClient({ url: "ws://[::1]:18789" });
client.start();
const last = wsMockState.last as {
const last = startGatewayClient({ url: "ws://[::1]:18789" }) as {
noProxyDuringConstruction: unknown;
httpProxyDuringConstruction: unknown;
httpsProxyDuringConstruction: unknown;
@@ -306,6 +305,24 @@ function makeOperatorWsClient(connId: string, socket: TestSocket, scopes: string
} as GatewayWsClient["connect"]);
}
function makeOperatorWsClients(
entries: Array<{ connId: string; socket: TestSocket; scopes: string[] }>,
) {
return new Set<GatewayWsClient>(
entries.map(({ connId, socket, scopes }) => makeOperatorWsClient(connId, socket, scopes)),
);
}
function makeReadPairClients(
first: { connId: string; socket: TestSocket; scopes: string[] },
readSocket: TestSocket,
) {
return makeOperatorWsClients([
first,
{ connId: "c-read", socket: readSocket, scopes: ["operator.read"] },
]);
}
function makeScopedBroadcastClients() {
const pairingSocket = makeRecordingSocket();
const nodeSocket = makeRecordingSocket();
@@ -326,6 +343,42 @@ function makeScopedBroadcastClients() {
return { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients };
}
function makeScopedBroadcastContext() {
const scoped = makeScopedBroadcastClients();
return {
...scoped,
...createGatewayBroadcaster({ clients: scoped.clients }),
};
}
function sentEvents(socket: RecordingSocket) {
return socket.sent.map((frame) => frame.event);
}
function expectSentEvents(socket: RecordingSocket, events: string[]) {
expect(sentEvents(socket)).toEqual(events);
}
function sentEventSeq(socket: RecordingSocket) {
return socket.sent.map((frame) => [frame.event, frame.seq]);
}
function chatPayload() {
return { sessionKey: "agent:main:main", message: "secret" };
}
function chatSideResultPayload() {
return { sessionKey: "agent:main:main", text: "tool output" };
}
function broadcastChatClassEvents(
broadcast: ReturnType<typeof createGatewayBroadcaster>["broadcast"],
) {
broadcast("chat", chatPayload());
broadcast("agent", { type: "status", sessionKey: "agent:main:main" });
broadcast("chat.side_result", chatSideResultPayload());
}
describe("gateway broadcaster", () => {
it("filters approval and pairing events by scope", () => {
const approvalsSocket: TestSocket = {
@@ -367,42 +420,25 @@ describe("gateway broadcaster", () => {
});
it("requires operator.read for chat-class broadcast events", () => {
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients } =
makeScopedBroadcastClients();
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
makeScopedBroadcastContext();
const { broadcast } = createGatewayBroadcaster({ clients });
broadcast("chat", { sessionKey: "agent:main:main", message: "secret" });
broadcast("agent", { type: "status", sessionKey: "agent:main:main" });
broadcast("chat.side_result", { sessionKey: "agent:main:main", text: "tool output" });
broadcastChatClassEvents(broadcast);
expect(pairingSocket.send).not.toHaveBeenCalled();
expect(nodeSocket.send).not.toHaveBeenCalled();
expect(readSocket.send).toHaveBeenCalledTimes(3);
expect(writeSocket.send).toHaveBeenCalledTimes(3);
expect(adminSocket.send).toHaveBeenCalledTimes(3);
expect(readSocket.sent.map((frame) => frame.event)).toEqual([
"chat",
"agent",
"chat.side_result",
]);
expect(writeSocket.sent.map((frame) => frame.event)).toEqual([
"chat",
"agent",
"chat.side_result",
]);
expect(adminSocket.sent.map((frame) => frame.event)).toEqual([
"chat",
"agent",
"chat.side_result",
]);
const expectedEvents = ["chat", "agent", "chat.side_result"];
expectSentEvents(readSocket, expectedEvents);
expectSentEvents(writeSocket, expectedEvents);
expectSentEvents(adminSocket, expectedEvents);
});
it("allows plugin.* broadcast events for operator.write and operator.admin", () => {
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients } =
makeScopedBroadcastClients();
const { broadcast } = createGatewayBroadcaster({ clients });
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
makeScopedBroadcastContext();
broadcast("plugin.myplugin.custom", { data: "test" });
broadcast("plugin.otherplugin.state", { state: "updated" });
@@ -412,21 +448,14 @@ describe("gateway broadcaster", () => {
expect(readSocket.send).not.toHaveBeenCalled();
expect(writeSocket.send).toHaveBeenCalledTimes(2);
expect(adminSocket.send).toHaveBeenCalledTimes(2);
expect(writeSocket.sent.map((frame) => frame.event)).toEqual([
"plugin.myplugin.custom",
"plugin.otherplugin.state",
]);
expect(adminSocket.sent.map((frame) => frame.event)).toEqual([
"plugin.myplugin.custom",
"plugin.otherplugin.state",
]);
const expectedEvents = ["plugin.myplugin.custom", "plugin.otherplugin.state"];
expectSentEvents(writeSocket, expectedEvents);
expectSentEvents(adminSocket, expectedEvents);
});
it("defaults unknown events to deny and classifies remaining gateway broadcast events", () => {
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients } =
makeScopedBroadcastClients();
const { broadcast } = createGatewayBroadcaster({ clients });
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
makeScopedBroadcastContext();
broadcast("cron", { jobId: "job-1" });
broadcast("talk.mode", { enabled: true });
@@ -440,7 +469,7 @@ describe("gateway broadcaster", () => {
broadcast("update.available", { updateAvailable: { version: "2026.4.20" } });
broadcast("unknown.future.event", { hidden: true });
expect(pairingSocket.sent.map((frame) => frame.event)).toEqual([
expectSentEvents(pairingSocket, [
"heartbeat",
"presence",
"health",
@@ -448,7 +477,7 @@ describe("gateway broadcaster", () => {
"shutdown",
"update.available",
]);
expect(nodeSocket.sent.map((frame) => frame.event)).toEqual([
expectSentEvents(nodeSocket, [
"voicewake.changed",
"voicewake.routing.changed",
"heartbeat",
@@ -458,7 +487,7 @@ describe("gateway broadcaster", () => {
"shutdown",
"update.available",
]);
expect(readSocket.sent.map((frame) => frame.event)).toEqual([
expectSentEvents(readSocket, [
"cron",
"voicewake.changed",
"voicewake.routing.changed",
@@ -469,7 +498,7 @@ describe("gateway broadcaster", () => {
"shutdown",
"update.available",
]);
expect(writeSocket.sent.map((frame) => frame.event)).toEqual([
const writeVisibleEvents = [
"cron",
"talk.mode",
"voicewake.changed",
@@ -480,42 +509,32 @@ describe("gateway broadcaster", () => {
"tick",
"shutdown",
"update.available",
]);
expect(adminSocket.sent.map((frame) => frame.event)).toEqual([
"cron",
"talk.mode",
"voicewake.changed",
"voicewake.routing.changed",
"heartbeat",
"presence",
"health",
"tick",
"shutdown",
"update.available",
]);
];
expectSentEvents(writeSocket, writeVisibleEvents);
expectSentEvents(adminSocket, writeVisibleEvents);
});
it("keeps event seq contiguous per receiving client when scoped events are filtered", () => {
const pairingSocket = makeRecordingSocket();
const readSocket = makeRecordingSocket();
const clients = new Set<GatewayWsClient>([
makeOperatorWsClient("c-pairing", pairingSocket, ["operator.pairing"]),
makeOperatorWsClient("c-read", readSocket, ["operator.read"]),
]);
const clients = makeReadPairClients(
{ connId: "c-pairing", socket: pairingSocket, scopes: ["operator.pairing"] },
readSocket,
);
const { broadcast } = createGatewayBroadcaster({ clients });
broadcast("chat", { sessionKey: "agent:main:main", message: "secret" });
broadcast("chat", chatPayload());
broadcast("heartbeat", { ts: 1 });
broadcast("chat.side_result", { sessionKey: "agent:main:main", text: "tool output" });
broadcast("chat.side_result", chatSideResultPayload());
broadcast("tick", { ts: 2 });
expect(pairingSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([
expect(sentEventSeq(pairingSocket)).toEqual([
["heartbeat", 1],
["tick", 2],
]);
expect(readSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([
expect(sentEventSeq(readSocket)).toEqual([
["chat", 1],
["heartbeat", 2],
["chat.side_result", 3],
@@ -527,10 +546,10 @@ describe("gateway broadcaster", () => {
const firstSocket = makeRecordingSocket();
const secondSocket = makeRecordingSocket();
const thirdSocket = makeRecordingSocket();
const clients = new Set<GatewayWsClient>([
makeOperatorWsClient("c-1", firstSocket, ["operator.read"]),
makeOperatorWsClient("c-2", secondSocket, ["operator.write"]),
makeOperatorWsClient("c-3", thirdSocket, ["operator.admin"]),
const clients = makeOperatorWsClients([
{ connId: "c-1", socket: firstSocket, scopes: ["operator.read"] },
{ connId: "c-2", socket: secondSocket, scopes: ["operator.write"] },
{ connId: "c-3", socket: thirdSocket, scopes: ["operator.admin"] },
]);
const payloadKeys: string[] = [];
const payload = {
@@ -560,21 +579,19 @@ describe("gateway broadcaster", () => {
slowReadSocket.bufferedAmount = Number.MAX_SAFE_INTEGER;
const readSocket = makeRecordingSocket();
const clients = new Set<GatewayWsClient>([
makeOperatorWsClient("c-slow-read", slowReadSocket, ["operator.read"]),
makeOperatorWsClient("c-read", readSocket, ["operator.read"]),
]);
const clients = makeReadPairClients(
{ connId: "c-slow-read", socket: slowReadSocket, scopes: ["operator.read"] },
readSocket,
);
const { broadcast } = createGatewayBroadcaster({ clients });
broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true });
broadcast("chat", chatPayload(), { dropIfSlow: true });
slowReadSocket.bufferedAmount = 0;
broadcast("heartbeat", { ts: 1 });
expect(slowReadSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([
["heartbeat", 2],
]);
expect(readSocket.sent.map((frame) => [frame.event, frame.seq])).toEqual([
expect(sentEventSeq(slowReadSocket)).toEqual([["heartbeat", 2]]);
expect(sentEventSeq(readSocket)).toEqual([
["chat", 1],
["heartbeat", 2],
]);
@@ -593,7 +610,7 @@ describe("gateway broadcaster", () => {
const { broadcast } = createGatewayBroadcaster({ clients });
broadcast("chat", { sessionKey: "agent:main:main", message: "secret" }, { dropIfSlow: true });
broadcast("chat", chatPayload(), { dropIfSlow: true });
broadcast("heartbeat", { ts: 1 });
const payloadEvent = events.find((event) => event.type === "payload.large");
@@ -755,6 +772,22 @@ describe("node subscription manager", () => {
});
describe("resolveNodeCommandAllowlist", () => {
function expectAllowed(allow: { has: (cmd: string) => boolean }, commands: string[]) {
for (const cmd of commands) {
expect(allow.has(cmd)).toBe(true);
}
}
function expectDenied(allow: { has: (cmd: string) => boolean }, commands: string[]) {
for (const cmd of commands) {
expect(allow.has(cmd)).toBe(false);
}
}
function expectDangerousCommandsDenied(allow: { has: (cmd: string) => boolean }) {
expectDenied(allow, DEFAULT_DANGEROUS_NODE_COMMANDS);
}
it("includes iOS service commands by default", () => {
const allow = resolveNodeCommandAllowlist(
{},
@@ -764,18 +797,17 @@ describe("resolveNodeCommandAllowlist", () => {
},
);
expect(allow.has("device.info")).toBe(true);
expect(allow.has("device.status")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
expect(allow.has("contacts.search")).toBe(true);
expect(allow.has("calendar.events")).toBe(true);
expect(allow.has("reminders.list")).toBe(true);
expect(allow.has("photos.latest")).toBe(true);
expect(allow.has("motion.activity")).toBe(true);
for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) {
expect(allow.has(cmd)).toBe(false);
}
expectAllowed(allow, [
"device.info",
"device.status",
"system.notify",
"contacts.search",
"calendar.events",
"reminders.list",
"photos.latest",
"motion.activity",
]);
expectDangerousCommandsDenied(allow);
});
it("includes Android notifications and device diagnostics commands by default", () => {
@@ -787,13 +819,15 @@ describe("resolveNodeCommandAllowlist", () => {
},
);
expect(allow.has("notifications.list")).toBe(true);
expect(allow.has("notifications.actions")).toBe(true);
expect(allow.has("device.permissions")).toBe(true);
expect(allow.has("device.health")).toBe(true);
expect(allow.has("callLog.search")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
expect(allow.has("sms.search")).toBe(false);
expectAllowed(allow, [
"notifications.list",
"notifications.actions",
"device.permissions",
"device.health",
"callLog.search",
"system.notify",
]);
expectDenied(allow, ["sms.search"]);
});
it("treats sms.search as dangerous by default", () => {
@@ -826,20 +860,18 @@ describe("resolveNodeCommandAllowlist", () => {
},
);
expect(allow.has("canvas.present")).toBe(false);
expect(allow.has("canvas.a2ui.pushJSONL")).toBe(false);
expect(allow.has("camera.list")).toBe(true);
expect(allow.has("location.get")).toBe(true);
expect(allow.has("device.info")).toBe(true);
expect(allow.has("device.status")).toBe(true);
expect(allow.has("screen.snapshot")).toBe(true);
expect(allow.has("system.run")).toBe(true);
expect(allow.has("system.which")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) {
expect(allow.has(cmd)).toBe(false);
}
expectDenied(allow, ["canvas.present", "canvas.a2ui.pushJSONL"]);
expectAllowed(allow, [
"camera.list",
"location.get",
"device.info",
"device.status",
"screen.snapshot",
"system.run",
"system.which",
"system.notify",
]);
expectDangerousCommandsDenied(allow);
});
it("can explicitly allow dangerous commands via allowCommands", () => {
@@ -867,9 +899,8 @@ describe("resolveNodeCommandAllowlist", () => {
},
);
expect(allow.has("system.run")).toBe(false);
expect(allow.has("system.which")).toBe(false);
expect(allow.has("system.notify")).toBe(true);
expectDenied(allow, ["system.run", "system.which"]);
expectAllowed(allow, ["system.notify"]);
});
it("normalizes dotted-I platform values to iOS classification", () => {
@@ -881,9 +912,8 @@ describe("resolveNodeCommandAllowlist", () => {
},
);
expect(allow.has("system.run")).toBe(false);
expect(allow.has("system.which")).toBe(false);
expect(allow.has("device.info")).toBe(true);
expectDenied(allow, ["system.run", "system.which"]);
expectAllowed(allow, ["device.info"]);
});
});