@@ -42,6 +42,7 @@ import {
4242 setupWorkspaceFileDocHandlers ,
4343} from '@/handlers/file-doc'
4444import { FileDocInvalidatedError , getFileDocStore } from '@/handlers/file-doc-store'
45+ import { publishRoomPermission } from '@/handlers/room-permission'
4546import * as permissions from '@/middleware/permissions'
4647import {
4748 beginRoomPermissionRead ,
@@ -642,6 +643,93 @@ describe('setupWorkspaceFileDocHandlers', () => {
642643 }
643644 } )
644645
646+ it ( 'publishes write access to a socket only when it changes, not on every frame' , async ( ) => {
647+ // A distinct user/file: role decisions recorded under fake timers outlive this test.
648+ vi . useFakeTimers ( )
649+ try {
650+ const { io } = createIo ( )
651+ const { socket, handlers } = setup ( 'socket-capability' , io , { userId : 'user-capability' } )
652+ await handlers [ FILE_DOC_EVENTS . JOIN ] ( { fileId : 'file-capability' , clientId : 7 } )
653+ await vi . advanceTimersByTimeAsync ( 0 )
654+ const presence = awarenessFrame ( 7 , 'Capability' ) . frame
655+ const sendFrames = async ( ) => {
656+ for ( let sent = 0 ; sent < 3 ; sent ++ ) {
657+ void handlers [ FILE_DOC_EVENTS . MESSAGE ] ( presence )
658+ await vi . advanceTimersByTimeAsync ( 0 )
659+ }
660+ }
661+ const published = ( ) =>
662+ socket . emit . mock . calls
663+ . filter ( ( [ event ] ) => event === FILE_DOC_EVENTS . PERMISSION )
664+ . map ( ( [ , payload ] ) => ( payload as { canWrite : boolean } ) . canWrite )
665+ const changeRole = async ( workspacePermission : 'read' | 'write' ) => {
666+ mockAuthorizeRoom . mockResolvedValue ( {
667+ allowed : true ,
668+ status : 200 ,
669+ workspaceId : 'ws-1' ,
670+ workspacePermission,
671+ } )
672+ await vi . advanceTimersByTimeAsync ( ROLE_REVALIDATION_TTL_MS + 1_000 )
673+ }
674+
675+ await sendFrames ( )
676+ expect ( published ( ) ) . toEqual ( [ true ] )
677+
678+ await changeRole ( 'read' )
679+ await sendFrames ( )
680+ expect ( published ( ) ) . toEqual ( [ true , false ] )
681+
682+ await changeRole ( 'write' )
683+ await sendFrames ( )
684+ expect ( published ( ) ) . toEqual ( [ true , false , true ] )
685+ } finally {
686+ vi . useRealTimers ( )
687+ }
688+ } )
689+
690+ it ( 'tells an admitted writer it can write even if its cached role lapses before the reply' , async ( ) => {
691+ // A distinct user/file: role decisions recorded under fake timers outlive this test.
692+ vi . useFakeTimers ( { toFake : [ 'Date' ] } )
693+ try {
694+ const { io } = createIo ( )
695+ const data : Record < string , unknown > = { }
696+ // Registration records presence just before the reply; expire the role cache right there.
697+ Object . defineProperty ( data , 'fileDocPresence' , {
698+ set : ( ) => vi . setSystemTime ( Date . now ( ) + ROLE_REVALIDATION_TTL_MS + 1 ) ,
699+ } )
700+ const { socket, handlers } = setup ( 'socket-lapse' , io , { userId : 'user-lapse' , data } )
701+ await handlers [ FILE_DOC_EVENTS . JOIN ] ( { fileId : 'file-lapse' , clientId : 9 } )
702+
703+ expect ( socket . emit ) . toHaveBeenCalledWith (
704+ FILE_DOC_EVENTS . JOIN_SUCCESS ,
705+ expect . objectContaining ( { fileId : 'file-lapse' , canWrite : true } )
706+ )
707+ } finally {
708+ vi . useRealTimers ( )
709+ }
710+ } )
711+
712+ it ( 'publishes a re-validated permission only when it changes what the socket last heard' , async ( ) => {
713+ const { io } = createIo ( )
714+ const { socket, handlers } = setup ( 'socket-sweep' , io )
715+ await handlers [ FILE_DOC_EVENTS . JOIN ] ( { fileId : 'file-1' , clientId : 8 } )
716+ await handlers [ FILE_DOC_EVENTS . MESSAGE ] ( awarenessFrame ( 8 , 'Sweep' ) . frame )
717+ const room = { type : ROOM_TYPES . WORKSPACE_FILE_DOC , id : 'file-1' } as const
718+ const subscriber = socket as unknown as Parameters < typeof publishRoomPermission > [ 0 ]
719+ const published = ( ) =>
720+ socket . emit . mock . calls
721+ . filter ( ( [ event ] ) => event === FILE_DOC_EVENTS . PERMISSION )
722+ . map ( ( [ , payload ] ) => ( payload as { canWrite : boolean } ) . canWrite )
723+
724+ publishRoomPermission ( subscriber , room , 'write' )
725+ publishRoomPermission ( subscriber , room , 'write' )
726+ expect ( published ( ) ) . toEqual ( [ true ] )
727+
728+ publishRoomPermission ( subscriber , room , 'read' )
729+ publishRoomPermission ( subscriber , room , 'read' )
730+ expect ( published ( ) ) . toEqual ( [ true , false ] )
731+ } )
732+
645733 it ( 'applies + fans out an agent-streamed frame (SYNC_NO_PERSIST) but never persists it' , async ( ) => {
646734 mockFetchFileDocSeed . mockResolvedValue ( seedResult ( '# From server' ) )
647735 const { io, sent } = createIo ( )
0 commit comments