fix(tree): place remote moves by position; remove stale node on move-into-restricted
Release-cycle review found two move-path issues: - Remote moves were placed at index:0 (broadcastPageMoved hardcodes index:0), so every observer rendered the moved node at the TOP of its new siblings until refetch. Client moveTreeNode now places by fractional position (treeModel.placeByPosition, mirroring addTreeNode/insertByPosition) and applies the payload's pageData (title->name, icon, hasChildren) so receivers keep the node correct. - Moving a page under a restricted ancestor left a stale named node (title/ slugId/icon) in the trees of users who lost visibility. broadcastPageMoved now derives one FRESH hasRestrictedAncestor decision and drives both paths from it: when restricted, the move goes to authorized users only (emitToAuthorizedUsers, not the space-cache-gated emitTreeEvent) and a compensating deleteTreeNode goes to the unauthorized complement (same fresh getUserIdsWithPageAccess set) — disjoint, no stale-cache window. Non-restricted moves are unchanged (one moveTreeNode to the room). Follow-up (noted): invalidateSpaceRestrictionCache is still unwired at permission-mutation sites; the open-space fast path can lag up to the 30s TTL, but the move/delete consistency above no longer depends on it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,16 +21,33 @@ const snapshot: TreeNodeSnapshot = {
|
||||
|
||||
describe('WsTreeService', () => {
|
||||
let service: WsTreeService;
|
||||
let wsService: { emitTreeEvent: jest.Mock; emitToSpaceRoom: jest.Mock };
|
||||
let wsService: {
|
||||
emitTreeEvent: jest.Mock;
|
||||
emitToSpaceRoom: jest.Mock;
|
||||
emitDeleteToUnauthorized: jest.Mock;
|
||||
emitToAuthorizedUsers: jest.Mock;
|
||||
};
|
||||
let pagePermissionRepo: { hasRestrictedAncestor: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
wsService = {
|
||||
emitTreeEvent: jest.fn().mockResolvedValue(undefined),
|
||||
emitToSpaceRoom: jest.fn(),
|
||||
emitDeleteToUnauthorized: jest.fn().mockResolvedValue(undefined),
|
||||
emitToAuthorizedUsers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pagePermissionRepo = {
|
||||
// Default: not restricted, so broadcastPageMoved skips the compensating
|
||||
// delete unless a test opts in.
|
||||
hasRestrictedAncestor: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WsTreeService, { provide: WsService, useValue: wsService }],
|
||||
providers: [
|
||||
WsTreeService,
|
||||
{ provide: WsService, useValue: wsService },
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsTreeService>(WsTreeService);
|
||||
@@ -118,6 +135,76 @@ describe('WsTreeService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('broadcastPageMoved into an UNrestricted location does NOT emit a compensating delete', async () => {
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false);
|
||||
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'new-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await service.broadcastPageMoved(event);
|
||||
|
||||
// Normal path: move goes to the whole room via emitTreeEvent, and neither
|
||||
// the authorized-only move path nor the compensating delete fire.
|
||||
expect(wsService.emitTreeEvent).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitToAuthorizedUsers).not.toHaveBeenCalled();
|
||||
expect(wsService.emitDeleteToUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcastPageMoved into a RESTRICTED subtree routes the move to authorized users only AND emits a compensating delete to unauthorized — from one fresh decision', async () => {
|
||||
// Destination is now under a restricted ancestor.
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
|
||||
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'restricted-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await service.broadcastPageMoved(event);
|
||||
|
||||
// The single fresh restriction decision was read exactly once...
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledTimes(1);
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
);
|
||||
|
||||
// ...and it must NOT go through the cache-gated room-wide emitTreeEvent,
|
||||
// which could leak the move to the whole room during the stale-cache window.
|
||||
expect(wsService.emitTreeEvent).not.toHaveBeenCalled();
|
||||
|
||||
// The move is delivered to authorized users only.
|
||||
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({
|
||||
operation: 'moveTreeNode',
|
||||
spaceId: 'space-1',
|
||||
payload: expect.objectContaining({ id: 'page-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
// The users who lost access get a deleteTreeNode for the moved node, scoped
|
||||
// to the same page id (same fresh authorized set → disjoint from the move).
|
||||
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({
|
||||
operation: 'deleteTreeNode',
|
||||
spaceId: 'space-1',
|
||||
payload: {
|
||||
node: expect.objectContaining({ id: 'page-1', slugId: 'slug-1' }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('broadcastRefetchRoot emits refetchRootTreeNodeEvent to the space room', async () => {
|
||||
await service.broadcastRefetchRoot('space-7');
|
||||
|
||||
@@ -203,4 +290,30 @@ describe('WsService.emitTreeEvent', () => {
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(noEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emitDeleteToUnauthorized sends ONLY to sockets whose user lacks page access', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const noEmit = jest.fn();
|
||||
const anonEmit = jest.fn();
|
||||
const sockets = [
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
||||
// Unauthenticated socket (no userId) — must also receive the delete.
|
||||
{ id: 's3', data: {}, emit: anonEmit },
|
||||
];
|
||||
server.in.mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
});
|
||||
|
||||
const data = { operation: 'deleteTreeNode' };
|
||||
await service.emitDeleteToUnauthorized('space-1', 'page-1', data);
|
||||
|
||||
// Authorized user does NOT get the delete (they got the move instead).
|
||||
expect(okEmit).not.toHaveBeenCalled();
|
||||
// Unauthorized + anonymous sockets DO get the delete.
|
||||
expect(noEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', data);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { WsService } from './ws.service';
|
||||
import {
|
||||
PageMovedEvent,
|
||||
@@ -8,7 +9,10 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class WsTreeService {
|
||||
constructor(private readonly wsService: WsService) {}
|
||||
constructor(
|
||||
private readonly wsService: WsService,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
// Server-origin tree broadcasts. Built from thin node snapshots carried in the
|
||||
// domain events (variant A) so no DB read happens here — this avoids the
|
||||
@@ -56,7 +60,8 @@ export class WsTreeService {
|
||||
|
||||
async broadcastPageMoved(event: PageMovedEvent): Promise<void> {
|
||||
const { node } = event;
|
||||
await this.wsService.emitTreeEvent(node.spaceId, node.id, {
|
||||
|
||||
const movePayload = {
|
||||
operation: 'moveTreeNode',
|
||||
spaceId: node.spaceId,
|
||||
payload: {
|
||||
@@ -77,6 +82,57 @@ export class WsTreeService {
|
||||
hasChildren: event.hasChildren,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Decide the node's restricted state ONCE, fresh (uncached), and drive BOTH
|
||||
// the move broadcast and the compensating delete from this single decision.
|
||||
//
|
||||
// Why not just emitTreeEvent for the move? emitTreeEvent gates the move on
|
||||
// the CACHED spaceHasRestrictions (30s TTL, never invalidated). In the window
|
||||
// right after a space gets its FIRST restriction, that cache still says
|
||||
// "no restrictions" → emitTreeEvent would fan the move out to the WHOLE room
|
||||
// (including unauthorized users) while the delete below (computed from the
|
||||
// UNCACHED hasRestrictedAncestor) also fires. An unauthorized user then gets
|
||||
// BOTH, and if the delete lands first it is a no-op and the later move
|
||||
// renders the restricted node → leak. So when the node is known-restricted we
|
||||
// must NOT route the move through the cache-gated path.
|
||||
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
|
||||
node.id,
|
||||
);
|
||||
|
||||
if (!isRestricted) {
|
||||
// Normal case: not under a restricted ancestor. One moveTreeNode to the
|
||||
// whole space room (emitTreeEvent's open-space fast path), no delete.
|
||||
await this.wsService.emitTreeEvent(node.spaceId, node.id, movePayload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restricted case: a move can push a previously-visible page UNDER a
|
||||
// restricted ancestor. Route the move to authorized users ONLY (same fresh
|
||||
// getUserIdsWithPageAccess set the delete uses) and send the compensating
|
||||
// delete to everyone else. Both sets come from one fresh decision, so they
|
||||
// are guaranteed disjoint: authorized users get exactly the moveTreeNode,
|
||||
// unauthorized users get exactly the deleteTreeNode, nobody gets both.
|
||||
//
|
||||
// Users who LOSE visibility need the delete because otherwise the node would
|
||||
// linger in their tree at its old parent with its real title/slugId/icon
|
||||
// (existence + metadata leak).
|
||||
await this.wsService.emitToAuthorizedUsers(
|
||||
node.spaceId,
|
||||
node.id,
|
||||
movePayload,
|
||||
);
|
||||
|
||||
await this.wsService.emitDeleteToUnauthorized(node.spaceId, node.id, {
|
||||
operation: 'deleteTreeNode',
|
||||
spaceId: node.spaceId,
|
||||
payload: {
|
||||
node: {
|
||||
id: node.id,
|
||||
slugId: node.slugId,
|
||||
parentPageId: event.oldParentId ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,62 @@ export class WsService {
|
||||
this.server.to(getSpaceRoomName(spaceId)).emit('message', data);
|
||||
}
|
||||
|
||||
// Broadcast `data` (a deleteTreeNode) to every socket in the space room whose
|
||||
// user is NOT authorized to see `pageId`. Used to compensate a move that pushes
|
||||
// a previously-visible page UNDER a restricted ancestor: authorized users get
|
||||
// the moveTreeNode (via emitTreeEvent), everyone else gets a deleteTreeNode so
|
||||
// the now-restricted node disappears from their tree instead of lingering with
|
||||
// its real title/slugId/icon. The two event sets are disjoint by construction
|
||||
// (a user is either authorized or not), so no socket receives both.
|
||||
async emitDeleteToUnauthorized(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
const sockets = await this.server.in(room).fetchSockets();
|
||||
if (sockets.length === 0) return;
|
||||
|
||||
const userIds = Array.from(
|
||||
new Set(
|
||||
sockets
|
||||
.map((s) => s.data.userId as string)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
);
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const authorizedUserIds =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds);
|
||||
const authorizedSet = new Set(authorizedUserIds);
|
||||
|
||||
for (const socket of sockets) {
|
||||
const userId = socket.data.userId as string;
|
||||
// Unauthenticated sockets (no userId) cannot see restricted content; send
|
||||
// them the delete too so a leaked node can't linger.
|
||||
if (!userId || !authorizedSet.has(userId)) {
|
||||
socket.emit('message', data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server-origin broadcast of `data` to exactly the users in the space room who
|
||||
// ARE authorized to see `pageId`. This is the counterpart of
|
||||
// emitDeleteToUnauthorized: both resolve the authorized set from the SAME
|
||||
// fetchSockets + getUserIdsWithPageAccess call shape, so a caller that drives
|
||||
// both from one decision gets two disjoint sets (authorized vs. not) with no
|
||||
// socket in both. Unlike emitTreeEvent, this does NOT consult the cached
|
||||
// spaceHasRestrictions: the caller already knows the page is restricted, so we
|
||||
// must not risk a stale cache fanning the move out to the whole room.
|
||||
async emitToAuthorizedUsers(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
|
||||
}
|
||||
|
||||
async emitToUsers(userIds: string[], data: any): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
const rooms = userIds.map((id) => getUserRoomName(id));
|
||||
|
||||
Reference in New Issue
Block a user