Skip to content

Commit 4886ec8

Browse files
Gateway: disconnect revoked device sessions
1 parent 4693813 commit 4886ec8

4 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { deviceHandlers } from "./devices.js";
3+
import type { GatewayRequestHandlerOptions } from "./types.js";
4+
5+
const { removePairedDeviceMock, revokeDeviceTokenMock } = vi.hoisted(() => ({
6+
removePairedDeviceMock: vi.fn(),
7+
revokeDeviceTokenMock: vi.fn(),
8+
}));
9+
10+
vi.mock("../../infra/device-pairing.js", async () => {
11+
const actual = await vi.importActual<typeof import("../../infra/device-pairing.js")>(
12+
"../../infra/device-pairing.js",
13+
);
14+
return {
15+
...actual,
16+
removePairedDevice: removePairedDeviceMock,
17+
revokeDeviceToken: revokeDeviceTokenMock,
18+
};
19+
});
20+
21+
function createOptions(
22+
method: string,
23+
params: Record<string, unknown>,
24+
overrides?: Partial<GatewayRequestHandlerOptions>,
25+
): GatewayRequestHandlerOptions {
26+
return {
27+
req: { type: "req", id: "req-1", method, params },
28+
params,
29+
client: null,
30+
isWebchatConnect: () => false,
31+
respond: vi.fn(),
32+
context: {
33+
disconnectClientsForDevice: vi.fn(),
34+
logGateway: {
35+
debug: vi.fn(),
36+
error: vi.fn(),
37+
info: vi.fn(),
38+
warn: vi.fn(),
39+
},
40+
},
41+
...overrides,
42+
} as unknown as GatewayRequestHandlerOptions;
43+
}
44+
45+
describe("deviceHandlers", () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
});
49+
50+
it("disconnects active clients after removing a paired device", async () => {
51+
removePairedDeviceMock.mockResolvedValue({ deviceId: "device-1", removedAtMs: 123 });
52+
const opts = createOptions("device.pair.remove", { deviceId: "device-1" });
53+
54+
await deviceHandlers["device.pair.remove"](opts);
55+
56+
expect(removePairedDeviceMock).toHaveBeenCalledWith("device-1");
57+
expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1");
58+
expect(opts.respond).toHaveBeenCalledWith(
59+
true,
60+
{ deviceId: "device-1", removedAtMs: 123 },
61+
undefined,
62+
);
63+
});
64+
65+
it("does not disconnect clients when device removal fails", async () => {
66+
removePairedDeviceMock.mockResolvedValue(null);
67+
const opts = createOptions("device.pair.remove", { deviceId: "device-1" });
68+
69+
await deviceHandlers["device.pair.remove"](opts);
70+
71+
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
72+
expect(opts.respond).toHaveBeenCalledWith(
73+
false,
74+
undefined,
75+
expect.objectContaining({ message: "unknown deviceId" }),
76+
);
77+
});
78+
79+
it("disconnects active clients after revoking a device token", async () => {
80+
revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
81+
const opts = createOptions("device.token.revoke", {
82+
deviceId: "device-1",
83+
role: "operator",
84+
});
85+
86+
await deviceHandlers["device.token.revoke"](opts);
87+
88+
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({ deviceId: "device-1", role: "operator" });
89+
expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1");
90+
expect(opts.respond).toHaveBeenCalledWith(
91+
true,
92+
{ deviceId: "device-1", role: "operator", revokedAtMs: 456 },
93+
undefined,
94+
);
95+
});
96+
97+
it("does not disconnect clients when token revocation fails", async () => {
98+
revokeDeviceTokenMock.mockResolvedValue(null);
99+
const opts = createOptions("device.token.revoke", {
100+
deviceId: "device-1",
101+
role: "operator",
102+
});
103+
104+
await deviceHandlers["device.token.revoke"](opts);
105+
106+
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
107+
expect(opts.respond).toHaveBeenCalledWith(
108+
false,
109+
undefined,
110+
expect.objectContaining({ message: "unknown deviceId/role" }),
111+
);
112+
});
113+
});

src/gateway/server-methods/devices.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
171171
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId"));
172172
return;
173173
}
174+
context.disconnectClientsForDevice?.(deviceId);
174175
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
175176
respond(true, removed, undefined);
176177
},
@@ -283,6 +284,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
283284
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
284285
return;
285286
}
287+
context.disconnectClientsForDevice?.(deviceId);
286288
context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`);
287289
respond(
288290
true,

src/gateway/server-methods/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type GatewayRequestContext = {
5757
nodeUnsubscribeAll: (nodeId: string) => void;
5858
hasConnectedMobileNode: () => boolean;
5959
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
60+
disconnectClientsForDevice?: (deviceId: string) => void;
6061
nodeRegistry: NodeRegistry;
6162
agentRunSeq: Map<string, number>;
6263
chatAbortControllers: Map<string, ChatAbortControllerEntry>;

src/gateway/server.impl.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,18 @@ export async function startGatewayServer(
11961196
}
11971197
return false;
11981198
},
1199+
disconnectClientsForDevice: (deviceId: string) => {
1200+
for (const gatewayClient of clients) {
1201+
if (gatewayClient.connect.device?.id !== deviceId) {
1202+
continue;
1203+
}
1204+
try {
1205+
gatewayClient.socket.close(4001, "device removed");
1206+
} catch {
1207+
/* ignore */
1208+
}
1209+
}
1210+
},
11991211
nodeRegistry,
12001212
agentRunSeq,
12011213
chatAbortControllers,

0 commit comments

Comments
 (0)