@@ -50,16 +50,14 @@ function makeManager(sockets: FakeSocket[], presence: Partial<UserPresence>[] =
5050 const manager = {
5151 io : { sockets : { sockets : socketMap } } ,
5252 isReady : ( ) => true ,
53- getWorkflowUsers : vi . fn ( ) . mockResolvedValue ( presence ) ,
54- getWorkflowIdForSocket : vi . fn ( ) . mockResolvedValue ( null ) ,
55- removeUserFromRoom : vi
56- . fn ( )
57- . mockImplementation ( async ( _socketId : string , workflowId ?: string ) => workflowId ?? null ) ,
53+ getRoomUsers : vi . fn ( ) . mockResolvedValue ( presence ) ,
54+ getRoomForSocket : vi . fn ( ) . mockResolvedValue ( null ) ,
55+ removeUserFromRoom : vi . fn ( ) . mockResolvedValue ( true ) ,
5856 broadcastPresenceUpdate : vi . fn ( ) . mockResolvedValue ( undefined ) ,
5957 }
6058 return manager as unknown as IRoomManager & {
61- getWorkflowUsers : ReturnType < typeof vi . fn >
62- getWorkflowIdForSocket : ReturnType < typeof vi . fn >
59+ getRoomUsers : ReturnType < typeof vi . fn >
60+ getRoomForSocket : ReturnType < typeof vi . fn >
6361 removeUserFromRoom : ReturnType < typeof vi . fn >
6462 broadcastPresenceUpdate : ReturnType < typeof vi . fn >
6563 }
@@ -84,8 +82,11 @@ describe('access-revalidation sweep', () => {
8482 expect . objectContaining ( { workflowId : 'wf-1' } )
8583 )
8684 expect ( socket . leave ) . toHaveBeenCalledWith ( 'wf-1' )
87- expect ( manager . removeUserFromRoom ) . toHaveBeenCalledWith ( 'sock-1' , 'wf-1' )
88- expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( 'wf-1' )
85+ expect ( manager . removeUserFromRoom ) . toHaveBeenCalledWith (
86+ { type : 'workflow' , id : 'wf-1' } ,
87+ 'sock-1'
88+ )
89+ expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( { type : 'workflow' , id : 'wf-1' } )
8990 } )
9091
9192 it ( 'keeps a socket whose access is still valid' , async ( ) => {
@@ -141,7 +142,30 @@ describe('access-revalidation sweep', () => {
141142
142143 expect ( mockResolveRole ) . toHaveBeenCalledWith ( 'user-1' , 'wf-1' , 'read' )
143144 // The security scan must stay Redis-free — presence is never consulted.
144- expect ( manager . getWorkflowUsers ) . not . toHaveBeenCalled ( )
145+ expect ( manager . getRoomUsers ) . not . toHaveBeenCalled ( )
146+ } )
147+
148+ it ( 'never evicts a socket joined only to a non-workflow room (files/tables/file-doc)' , async ( ) => {
149+ // The sweep shares one io with the files/tables/file-doc handlers. Those rooms are
150+ // namespaced (`workspace-files:ws-1`, `table:t-1`), so treating every socket.rooms
151+ // entry as a workflow id would resolve a bogus permission → null → evict the socket
152+ // from its files/table room every pass. Non-workflow rooms must be filtered out.
153+ const filesSocket = makeSocket ( 'sock-1' , 'user-1' , 'workspace-files:ws-1' )
154+ const tableSocket = makeSocket ( 'sock-2' , 'user-2' , 'table:t-1' )
155+ const manager = makeManager ( [ filesSocket , tableSocket ] )
156+ // Even if the role resolver would say "no access", these must never be swept.
157+ mockResolveRole . mockResolvedValue ( null )
158+
159+ const sweep = startAccessRevalidationSweep ( manager )
160+ await sweep . runOnce ( )
161+ sweep . stop ( )
162+
163+ expect ( mockResolveRole ) . not . toHaveBeenCalled ( )
164+ expect ( filesSocket . leave ) . not . toHaveBeenCalled ( )
165+ expect ( filesSocket . emit ) . not . toHaveBeenCalled ( )
166+ expect ( tableSocket . leave ) . not . toHaveBeenCalled ( )
167+ expect ( tableSocket . emit ) . not . toHaveBeenCalled ( )
168+ expect ( manager . removeUserFromRoom ) . not . toHaveBeenCalled ( )
145169 } )
146170
147171 it ( 'evicts only the revoked socket, not co-members of the room' , async ( ) => {
@@ -199,37 +223,37 @@ describe('access-revalidation sweep', () => {
199223 sweep . stop ( )
200224
201225 expect ( manager . removeUserFromRoom ) . toHaveBeenCalledTimes ( 2 )
202- expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( ' wf-1')
226+ expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( { type : 'workflow' , id : ' wf-1' } )
203227 } )
204228
205- it ( 'defers cleanup when removal fails with expired socket mappings ' , async ( ) => {
229+ it ( 'drops eviction cleanup when the socket is no longer mapped to the room (no infinite retry) ' , async ( ) => {
206230 const socket = makeSocket ( 'sock-1' , 'user-1' , 'wf-1' )
207231 const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
208- // Mapping keys already expired (lookup resolves null) AND the removal fails
209- // (the Redis manager swallows the transport error into null) — the failed
210- // removal must still defer instead of reading as success.
211- manager . removeUserFromRoom . mockResolvedValueOnce ( null )
232+ // A healthy lookup shows the socket is no longer mapped to any workflow room (its presence
233+ // is already gone), and removeUserFromRoom reports a no-op `false`. This is "already clean",
234+ // not a deferrable failure — the cleanup must drop it, never re-enqueue a still-connected
235+ // socket forever. (A genuine failure — still mapped + false — is covered by the next test.)
236+ manager . getRoomForSocket . mockResolvedValue ( null )
237+ manager . removeUserFromRoom . mockResolvedValue ( false )
212238 mockResolveRole . mockResolvedValue ( null )
213239
214240 const sweep = startAccessRevalidationSweep ( manager )
215241 await sweep . runOnce ( )
216-
217- expect ( manager . broadcastPresenceUpdate ) . not . toHaveBeenCalled ( )
218-
219242 await sweep . runOnce ( )
220243 sweep . stop ( )
221244
222- expect ( manager . removeUserFromRoom ) . toHaveBeenCalledTimes ( 2 )
223- expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( 'wf-1' )
245+ // Attempted once, then dropped — not re-enqueued across passes, and no broadcast.
246+ expect ( manager . removeUserFromRoom ) . toHaveBeenCalledTimes ( 1 )
247+ expect ( manager . broadcastPresenceUpdate ) . not . toHaveBeenCalled ( )
224248 } )
225249
226250 it ( 'defers cleanup when the manager swallows a removal failure into null' , async ( ) => {
227251 const socket = makeSocket ( 'sock-1' , 'user-1' , 'wf-1' )
228252 const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
229253 // Live mapping but the removal reports nothing removed — the Redis manager
230254 // swallows transport errors into null, so this is the only failure signal.
231- manager . getWorkflowIdForSocket . mockResolvedValue ( ' wf-1')
232- manager . removeUserFromRoom . mockResolvedValueOnce ( null )
255+ manager . getRoomForSocket . mockResolvedValue ( { type : 'workflow' , id : ' wf-1' } )
256+ manager . removeUserFromRoom . mockResolvedValueOnce ( false )
233257 mockResolveRole . mockResolvedValue ( null )
234258
235259 const sweep = startAccessRevalidationSweep ( manager )
@@ -243,15 +267,15 @@ describe('access-revalidation sweep', () => {
243267 sweep . stop ( )
244268
245269 expect ( manager . removeUserFromRoom ) . toHaveBeenCalledTimes ( 2 )
246- expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( ' wf-1')
270+ expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( { type : 'workflow' , id : ' wf-1' } )
247271 } )
248272
249273 it ( 'skips removal when the socket has since moved to a different workflow' , async ( ) => {
250274 const socket = makeSocket ( 'sock-1' , 'user-1' , 'wf-1' )
251275 const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
252276 // Between the membership snapshot and cleanup, the socket switched to a
253277 // workflow it can still access — removal must not touch its new presence.
254- manager . getWorkflowIdForSocket . mockResolvedValue ( ' wf-2')
278+ manager . getRoomForSocket . mockResolvedValue ( { type : 'workflow' , id : ' wf-2' } )
255279 mockResolveRole . mockResolvedValue ( null )
256280
257281 const sweep = startAccessRevalidationSweep ( manager )
@@ -357,7 +381,7 @@ describe('access-revalidation sweep', () => {
357381 const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
358382 // A Redis outage where commands hang in the offline queue instead of
359383 // failing: the cleanup lane stalls, but scans must keep running.
360- manager . getWorkflowIdForSocket . mockReturnValue ( new Promise ( ( ) => { } ) )
384+ manager . getRoomForSocket . mockReturnValue ( new Promise ( ( ) => { } ) )
361385 mockResolveRole . mockResolvedValue ( null )
362386
363387 const sweep = startAccessRevalidationSweep ( manager )
0 commit comments