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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user