1- import { describe , expect , it } from "vitest" ;
1+ import type { FastifyRequest } from "fastify" ;
2+ import { describe , expect , it , vi } from "vitest" ;
23import { ActivationManager } from "../ws/activation.js" ;
34import { type CommandContext , dispatch } from "../ws/dispatch.js" ;
5+ import type { Broadcaster } from "../ws/hub.js" ;
46import "../commands/activation.js" ;
7+ import "../commands/workspace.js" ;
8+
9+ function createMockRequest ( ) : FastifyRequest {
10+ return {
11+ ip : "127.0.0.1" ,
12+ headers : { "user-agent" : "test-agent" } ,
13+ } as unknown as FastifyRequest ;
14+ }
15+
16+ function createBaseContext ( overrides ?: {
17+ broadcaster ?: Broadcaster ;
18+ activationMgr ?: ActivationManager ;
19+ } ) : CommandContext {
20+ return {
21+ workspaceMgr : {
22+ list : vi . fn ( ( ) => [ { id : "workspace-1" } ] ) ,
23+ } as unknown as CommandContext [ "workspaceMgr" ] ,
24+ sessionMgr : { } as never ,
25+ terminalMgr : { } as never ,
26+ eventBus : { } as never ,
27+ broadcaster :
28+ overrides ?. broadcaster ??
29+ ( {
30+ broadcast : vi . fn ( ) ,
31+ sendToClient : vi . fn ( ( ) => true ) ,
32+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
33+ } as unknown as Broadcaster ) ,
34+ db : { } as never ,
35+ providerRegistry : [ ] ,
36+ fencingMgr : { } as never ,
37+ supervisorMgr : { } as never ,
38+ autoFetch : { } as never ,
39+ activationMgr : overrides ?. activationMgr ?? new ActivationManager ( ) ,
40+ } ;
41+ }
542
643describe ( "activation commands" , ( ) => {
744 it ( "returns generation data from activation.claim" , async ( ) => {
8- const ctx = {
9- activationMgr : new ActivationManager ( ) ,
10- } as unknown as CommandContext ;
45+ const request = createMockRequest ( ) ;
46+ const broadcaster = {
47+ broadcast : vi . fn ( ) ,
48+ sendToClient : vi . fn ( ( ) => true ) ,
49+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
50+ getRequestMetadata : vi . fn ( ( ) => request ) ,
51+ } satisfies Broadcaster ;
52+ const ctx = createBaseContext ( { broadcaster } ) ;
1153
1254 const result = await dispatch (
1355 {
@@ -26,12 +68,23 @@ describe("activation commands", () => {
2668 generation : 1 ,
2769 recoveryMode : "fresh" ,
2870 } ) ;
71+ expect ( broadcaster . getRequestMetadata ) . toHaveBeenCalledWith ( "ws-a" ) ;
72+ expect ( ctx . activationMgr . getLease ( ) ) . toMatchObject ( {
73+ clientInstanceId : "client-a" ,
74+ wsClientId : "ws-a" ,
75+ ip : "127.0.0.1" ,
76+ userAgent : "test-agent" ,
77+ } ) ;
2978 } ) ;
3079
31- it ( "rejects non-activation commands when activation is missing" , async ( ) => {
32- const ctx = {
33- activationMgr : new ActivationManager ( ) ,
34- } as unknown as CommandContext ;
80+ it ( "rejects non-activation commands for websocket clients without an active lease" , async ( ) => {
81+ const broadcaster = {
82+ broadcast : vi . fn ( ) ,
83+ sendToClient : vi . fn ( ( ) => true ) ,
84+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
85+ getRequestMetadata : vi . fn ( ( ) => createMockRequest ( ) ) ,
86+ } satisfies Broadcaster ;
87+ const ctx = createBaseContext ( { broadcaster } ) ;
3588
3689 const result = await dispatch (
3790 {
@@ -45,6 +98,192 @@ describe("activation commands", () => {
4598 ) ;
4699
47100 expect ( result . ok ) . toBe ( false ) ;
48- expect ( result . error ?. code ) . toBe ( "activation_required" ) ;
101+ expect ( result . error ) . toEqual ( {
102+ code : "activation_required" ,
103+ message : "This tab is no longer the active session" ,
104+ } ) ;
105+ } ) ;
106+
107+ it ( "rejects non-activation websocket commands when metadata lookup returns undefined" , async ( ) => {
108+ const broadcaster = {
109+ broadcast : vi . fn ( ) ,
110+ sendToClient : vi . fn ( ( ) => true ) ,
111+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
112+ getRequestMetadata : vi . fn ( ( ) => undefined ) ,
113+ } satisfies Broadcaster ;
114+ const ctx = createBaseContext ( { broadcaster } ) ;
115+
116+ const result = await dispatch (
117+ {
118+ kind : "command" ,
119+ id : "00000000-0000-4000-8000-000000000004" ,
120+ op : "workspace.list" ,
121+ args : { } ,
122+ } ,
123+ ctx ,
124+ "ws-a"
125+ ) ;
126+
127+ expect ( result . ok ) . toBe ( false ) ;
128+ expect ( result . error ) . toEqual ( {
129+ code : "activation_required" ,
130+ message : "This tab is no longer the active session" ,
131+ } ) ;
132+ } ) ;
133+
134+ it ( "does not allow a stale websocket to heartbeat after same-client rebind" , async ( ) => {
135+ const request = createMockRequest ( ) ;
136+ const broadcaster = {
137+ broadcast : vi . fn ( ) ,
138+ sendToClient : vi . fn ( ( ) => true ) ,
139+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
140+ getRequestMetadata : vi . fn ( ( ) => request ) ,
141+ } satisfies Broadcaster ;
142+ const ctx = createBaseContext ( { broadcaster } ) ;
143+
144+ await dispatch (
145+ {
146+ kind : "command" ,
147+ id : "claim-1" ,
148+ op : "activation.claim" ,
149+ args : { clientInstanceId : "client-a" } ,
150+ } ,
151+ ctx ,
152+ "ws-a"
153+ ) ;
154+
155+ const rebound = await dispatch (
156+ {
157+ kind : "command" ,
158+ id : "claim-2" ,
159+ op : "activation.claim" ,
160+ args : { clientInstanceId : "client-a" } ,
161+ } ,
162+ ctx ,
163+ "ws-b"
164+ ) ;
165+
166+ expect ( rebound . ok ) . toBe ( true ) ;
167+
168+ const heartbeat = await dispatch (
169+ {
170+ kind : "command" ,
171+ id : "heartbeat-stale" ,
172+ op : "activation.heartbeat" ,
173+ args : { clientInstanceId : "client-a" , generation : 1 } ,
174+ } ,
175+ ctx ,
176+ "ws-a"
177+ ) ;
178+
179+ expect ( heartbeat . ok ) . toBe ( true ) ;
180+ expect ( heartbeat . data ) . toEqual ( { ok : false } ) ;
181+ } ) ;
182+
183+ it ( "does not allow a stale websocket to release after same-client rebind" , async ( ) => {
184+ const request = createMockRequest ( ) ;
185+ const broadcaster = {
186+ broadcast : vi . fn ( ) ,
187+ sendToClient : vi . fn ( ( ) => true ) ,
188+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
189+ getRequestMetadata : vi . fn ( ( ) => request ) ,
190+ } satisfies Broadcaster ;
191+ const ctx = createBaseContext ( { broadcaster } ) ;
192+
193+ await dispatch (
194+ {
195+ kind : "command" ,
196+ id : "claim-3" ,
197+ op : "activation.claim" ,
198+ args : { clientInstanceId : "client-a" } ,
199+ } ,
200+ ctx ,
201+ "ws-a"
202+ ) ;
203+
204+ await dispatch (
205+ {
206+ kind : "command" ,
207+ id : "claim-4" ,
208+ op : "activation.claim" ,
209+ args : { clientInstanceId : "client-a" } ,
210+ } ,
211+ ctx ,
212+ "ws-b"
213+ ) ;
214+
215+ const release = await dispatch (
216+ {
217+ kind : "command" ,
218+ id : "release-stale" ,
219+ op : "activation.release" ,
220+ args : { clientInstanceId : "client-a" , generation : 1 } ,
221+ } ,
222+ ctx ,
223+ "ws-a"
224+ ) ;
225+
226+ expect ( release . ok ) . toBe ( true ) ;
227+ expect ( release . data ) . toEqual ( { ok : false } ) ;
228+ expect ( ctx . activationMgr . getLease ( ) ) . toMatchObject ( {
229+ clientInstanceId : "client-a" ,
230+ wsClientId : "ws-b" ,
231+ generation : 1 ,
232+ } ) ;
233+ } ) ;
234+
235+ it ( "claiming from a second client revokes the previous websocket" , async ( ) => {
236+ const request = createMockRequest ( ) ;
237+ const revokeAndCloseClient = vi . fn ( ) ;
238+ const ctx = createBaseContext ( {
239+ broadcaster : {
240+ broadcast : vi . fn ( ) ,
241+ sendToClient : vi . fn ( ( ) => true ) ,
242+ sendBinaryToClient : vi . fn ( ( ) => true ) ,
243+ getRequestMetadata : vi . fn ( ( ) => request ) ,
244+ revokeAndCloseClient,
245+ } as unknown as Broadcaster ,
246+ } ) ;
247+
248+ await dispatch (
249+ {
250+ kind : "command" ,
251+ id : "00000000-0000-4000-8000-000000000003" ,
252+ op : "activation.claim" ,
253+ args : { clientInstanceId : "client-a" } ,
254+ } ,
255+ ctx ,
256+ "ws-a"
257+ ) ;
258+
259+ await dispatch (
260+ {
261+ kind : "command" ,
262+ id : "00000000-0000-4000-8000-000000000004" ,
263+ op : "activation.claim" ,
264+ args : { clientInstanceId : "client-b" } ,
265+ } ,
266+ ctx ,
267+ "ws-b"
268+ ) ;
269+
270+ expect ( revokeAndCloseClient ) . toHaveBeenCalledWith ( "ws-a" , 2 ) ;
271+ } ) ;
272+
273+ it ( "does not block direct internal dispatches without websocket request metadata" , async ( ) => {
274+ const ctx = createBaseContext ( ) ;
275+
276+ const result = await dispatch (
277+ {
278+ kind : "command" ,
279+ id : "00000000-0000-4000-8000-000000000003" ,
280+ op : "workspace.list" ,
281+ args : { } ,
282+ } ,
283+ ctx
284+ ) ;
285+
286+ expect ( result . ok ) . toBe ( true ) ;
287+ expect ( result . data ) . toEqual ( [ { id : "workspace-1" } ] ) ;
49288 } ) ;
50289} ) ;
0 commit comments