refactor(ws): single restriction-aware emit for tree + comment events (#93)

emitTreeEvent and emitCommentEvent were byte-identical (same room resolution,
spaceHasRestrictions gate, hasRestrictedAncestor, authorized-only vs broadcast
fallback). Collapse the body into one private emitRestrictedAwareToSpace; both
stay thin wrappers with unchanged signatures, so the restriction-routing gate
lives in exactly one place. Adds coverage for the comment entry point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 03:49:52 +03:00
parent 7c57a386b2
commit 3147b6ddf4
2 changed files with 58 additions and 20 deletions

View File

@@ -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');

View File

@@ -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<void> {
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<void> {
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<void> {
const room = getSpaceRoomName(spaceId);