diff --git a/apps/server/src/ws/ws-tree.service.spec.ts b/apps/server/src/ws/ws-tree.service.spec.ts index 973e6b00..e007e249 100644 --- a/apps/server/src/ws/ws-tree.service.spec.ts +++ b/apps/server/src/ws/ws-tree.service.spec.ts @@ -294,6 +294,42 @@ describe('WsService.emitTreeEvent', () => { expect(noEmit).not.toHaveBeenCalled(); }); + it('emitCommentEvent open space: broadcasts to the whole space room', async () => { + // emitCommentEvent forwards to the SAME unified restriction gate as + // emitTreeEvent, so the open-space fast path must behave identically. + pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false); + + const data = { operation: 'addComment' }; + await service.emitCommentEvent('space-1', 'page-1', data); + + expect(server.to).toHaveBeenCalledWith(getSpaceRoomName('space-1')); + expect(roomEmit).toHaveBeenCalledWith('message', data); + expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled(); + }); + + it('emitCommentEvent restricted page: only authorized users receive the event', async () => { + pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true); + pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true); + pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']); + + const okEmit = jest.fn(); + const noEmit = jest.fn(); + const sockets = [ + { id: 's1', data: { userId: 'user-ok' }, emit: okEmit }, + { id: 's2', data: { userId: 'user-no' }, emit: noEmit }, + ]; + server.in.mockReturnValue({ + fetchSockets: jest.fn().mockResolvedValue(sockets), + }); + + const data = { operation: 'addComment' }; + await service.emitCommentEvent('space-1', 'page-1', data); + + expect(roomEmit).not.toHaveBeenCalled(); + expect(okEmit).toHaveBeenCalledWith('message', data); + expect(noEmit).not.toHaveBeenCalled(); + }); + it('invalidateSpaceRestrictionCache deletes the cached restriction verdict for that space only', async () => { await service.invalidateSpaceRestrictionCache('space-42'); diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 5c8303eb..18e20fb0 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -52,33 +52,19 @@ export class WsService { ); } + // Comment broadcast. Thin wrapper over the single restriction-aware emit so + // comment and tree events share ONE restriction gate (see + // emitRestrictedAwareToSpace). async emitCommentEvent( spaceId: string, pageId: string, data: any, ): Promise { - const room = getSpaceRoomName(spaceId); - - const hasRestrictions = await this.spaceHasRestrictions(spaceId); - if (!hasRestrictions) { - this.server.to(room).emit('message', data); - return; - } - - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(pageId); - if (!isRestricted) { - this.server.to(room).emit('message', data); - return; - } - - await this.broadcastToAuthorizedUsers(room, null, pageId, data); + await this.emitRestrictedAwareToSpace(spaceId, pageId, data); } - // Server-origin tree broadcast. Mirrors emitCommentEvent exactly: respects - // per-space page restrictions (spaceHasRestrictions -> hasRestrictedAncestor - // -> broadcastToAuthorizedUsers), otherwise fans the event out to everyone in - // the space room. + // Server-origin tree broadcast. Thin wrapper over the single restriction-aware + // emit (see emitRestrictedAwareToSpace), identical routing to emitCommentEvent. // // The author is NOT excluded. The client receiver is idempotent (addTreeNode // early-returns if the node id already exists; deleteTreeNode is a no-op if @@ -88,6 +74,22 @@ export class WsService { spaceId: string, pageId: string, data: any, + ): Promise { + await this.emitRestrictedAwareToSpace(spaceId, pageId, data); + } + + // The single restriction-aware space emit. This is the ONLY place that decides + // authorized-vs-unauthorized routing for server-origin space-room events + // (comment + tree). Both emitCommentEvent and emitTreeEvent forward to it with + // their own `data`; the payload/room/event are otherwise identical. + // + // Routing: if the space has no restrictions at all (cached fast path), or the + // page has no restricted ancestor, fan `data` out to the whole space room; + // otherwise restrict the broadcast to the users authorized to see `pageId`. + private async emitRestrictedAwareToSpace( + spaceId: string, + pageId: string, + data: any, ): Promise { const room = getSpaceRoomName(spaceId);