Implements Option 2 of #93. The restricted branch of broadcastPageMoved previously resolved its audience twice — emitToAuthorizedUsers and emitDeleteToUnauthorized each ran an independent fetchSockets + getUserIdsWithPageAccess — leaving a race window between the two snapshots where a socket could receive both the move and the delete (leak) or neither (lost compensating delete). - ws.service.ts: add emitMoveWithRestrictionSplit() that takes ONE socket snapshot and ONE authorization resolution, then partitions the room: authorized users get the moveTreeNode, everyone else (unauthorized + anonymous) get the compensating deleteTreeNode. Disjoint + complete by construction. Remove the now-unused emitToAuthorizedUsers / emitDeleteToUnauthorized; keep private broadcastToAuthorizedUsers (still used by emitRestrictedAwareToSpace). - ws-tree.service.ts: broadcastPageMoved restricted branch now drives move + delete from the single method. - specs: assert the single method is used and that fetchSockets / getUserIdsWithPageAccess are each called exactly once (single snapshot); re-route ws-service.spec to emitTreeEvent after the method removal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
266 lines
9.9 KiB
TypeScript
266 lines
9.9 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
import { WsService } from './ws.service';
|
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
import {
|
|
getSpaceRoomName,
|
|
WS_SPACE_RESTRICTION_CACHE_PREFIX,
|
|
WS_CACHE_TTL_MS,
|
|
} from './ws.utils';
|
|
|
|
/**
|
|
* WsService server-side unit tests (M7 item 2):
|
|
* - spaceHasRestrictions cache lifecycle (miss -> read+set with TTL; hit ->
|
|
* no re-read; documents the stale-false window).
|
|
* - broadcastToAuthorizedUsers fan-out (authorized-only delivery, multi-socket
|
|
* fan-out per user, sockets with no userId skipped).
|
|
*
|
|
* Both private methods are exercised through their public entry points:
|
|
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via the
|
|
* restricted-page path of emitTreeEvent. WsService is constructed with mocked
|
|
* cache + repo and a mocked socket.io server, so no live infra is needed.
|
|
*/
|
|
|
|
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
|
|
let service: WsService;
|
|
let pagePermissionRepo: {
|
|
hasRestrictedPagesInSpace: jest.Mock;
|
|
hasRestrictedAncestor: jest.Mock;
|
|
getUserIdsWithPageAccess: jest.Mock;
|
|
};
|
|
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
|
let roomEmit: jest.Mock;
|
|
|
|
beforeEach(async () => {
|
|
pagePermissionRepo = {
|
|
hasRestrictedPagesInSpace: jest.fn(),
|
|
hasRestrictedAncestor: jest.fn(),
|
|
getUserIdsWithPageAccess: jest.fn(),
|
|
};
|
|
cache = {
|
|
get: jest.fn().mockResolvedValue(null),
|
|
set: jest.fn().mockResolvedValue(undefined),
|
|
del: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
WsService,
|
|
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
|
{ provide: CACHE_MANAGER, useValue: cache },
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<WsService>(WsService);
|
|
|
|
roomEmit = jest.fn();
|
|
const server = {
|
|
to: jest.fn().mockReturnValue({ emit: roomEmit }),
|
|
in: jest.fn().mockReturnValue({ fetchSockets: jest.fn() }),
|
|
};
|
|
service.setServer(server as never);
|
|
});
|
|
|
|
const cacheKey = (spaceId: string): string =>
|
|
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
|
|
|
|
it('first call MISSES the cache -> reads the repo and sets it with WS_CACHE_TTL_MS', async () => {
|
|
cache.get.mockResolvedValue(null); // miss
|
|
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
|
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false);
|
|
|
|
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
|
|
|
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
|
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledTimes(1);
|
|
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledWith(
|
|
'space-1',
|
|
);
|
|
// The freshly-read verdict is cached with the 30s TTL.
|
|
expect(cache.set).toHaveBeenCalledWith(
|
|
cacheKey('space-1'),
|
|
true,
|
|
WS_CACHE_TTL_MS,
|
|
);
|
|
});
|
|
|
|
it('second call HITS the cache -> the repo is NOT re-read', async () => {
|
|
// Cache hit returns false (no restrictions) -> open-space fast path.
|
|
cache.get.mockResolvedValue(false);
|
|
|
|
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
|
|
|
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
|
// The whole point of the cache: no repo read on a hit.
|
|
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
|
expect(cache.set).not.toHaveBeenCalled();
|
|
// false verdict -> broadcast to the whole room (open-space fast path).
|
|
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'x' });
|
|
});
|
|
|
|
it('a cached `false` is returned even when restrictions now exist (the stale window)', async () => {
|
|
// The cache says "no restrictions" (false) but the repo, if asked, would now
|
|
// say true. spaceHasRestrictions trusts the cached false and never re-reads —
|
|
// this documents the up-to-TTL stale window the production comment warns about
|
|
// (a payload can fan out room-wide until the cache is invalidated/expires).
|
|
cache.get.mockResolvedValue(false);
|
|
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
|
|
|
await service.emitTreeEvent('space-1', 'page-1', { op: 'stale' });
|
|
|
|
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
|
// Treated as open -> the event is broadcast to the WHOLE room.
|
|
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'stale' });
|
|
});
|
|
|
|
it('caches a `false` verdict too (so the next emit hits, not re-reads)', async () => {
|
|
cache.get.mockResolvedValueOnce(null); // first call: miss
|
|
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false);
|
|
|
|
await service.emitTreeEvent('space-2', 'page-9', { op: 'y' });
|
|
|
|
expect(cache.set).toHaveBeenCalledWith(
|
|
cacheKey('space-2'),
|
|
false,
|
|
WS_CACHE_TTL_MS,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitTreeEvent restricted path)', () => {
|
|
let service: WsService;
|
|
let pagePermissionRepo: {
|
|
hasRestrictedPagesInSpace: jest.Mock;
|
|
hasRestrictedAncestor: jest.Mock;
|
|
getUserIdsWithPageAccess: jest.Mock;
|
|
};
|
|
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
|
let fetchSockets: jest.Mock;
|
|
let serverIn: jest.Mock;
|
|
|
|
beforeEach(async () => {
|
|
pagePermissionRepo = {
|
|
hasRestrictedPagesInSpace: jest.fn(),
|
|
hasRestrictedAncestor: jest.fn(),
|
|
getUserIdsWithPageAccess: jest.fn(),
|
|
};
|
|
cache = {
|
|
get: jest.fn().mockResolvedValue(null),
|
|
set: jest.fn().mockResolvedValue(undefined),
|
|
del: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
WsService,
|
|
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
|
{ provide: CACHE_MANAGER, useValue: cache },
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<WsService>(WsService);
|
|
|
|
fetchSockets = jest.fn();
|
|
serverIn = jest.fn().mockReturnValue({ fetchSockets });
|
|
const server = {
|
|
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
|
in: serverIn,
|
|
};
|
|
service.setServer(server as never);
|
|
|
|
// Reach broadcastToAuthorizedUsers through emitTreeEvent's restricted path:
|
|
// the space has restrictions (cache miss -> repo says true) and the page has
|
|
// a restricted ancestor, so the emit is scoped to the authorized users.
|
|
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
|
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
|
|
});
|
|
|
|
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
|
|
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
|
|
|
const okEmit = jest.fn();
|
|
const noEmit = jest.fn();
|
|
fetchSockets.mockResolvedValue([
|
|
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
|
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
|
]);
|
|
|
|
const data = { operation: 'moveTreeNode' };
|
|
await service.emitTreeEvent('space-1', 'page-1', data);
|
|
|
|
// The authorized set is resolved from the candidate userIds present on the
|
|
// sockets (deduped), then only those users' sockets get the event.
|
|
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
|
'page-1',
|
|
expect.arrayContaining(['user-ok', 'user-no']),
|
|
);
|
|
expect(okEmit).toHaveBeenCalledWith('message', data);
|
|
expect(noEmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a user with TWO sockets receives the event on BOTH (userSocketMap fan-out)', async () => {
|
|
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
|
|
|
const tab1 = jest.fn();
|
|
const tab2 = jest.fn();
|
|
fetchSockets.mockResolvedValue([
|
|
{ id: 's1', data: { userId: 'user-ok' }, emit: tab1 },
|
|
{ id: 's2', data: { userId: 'user-ok' }, emit: tab2 },
|
|
]);
|
|
|
|
const data = { operation: 'moveTreeNode' };
|
|
await service.emitTreeEvent('space-1', 'page-1', data);
|
|
|
|
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
|
|
expect(tab1).toHaveBeenCalledWith('message', data);
|
|
expect(tab2).toHaveBeenCalledWith('message', data);
|
|
// The candidate set is deduped to a single userId even with two sockets.
|
|
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
|
'page-1',
|
|
['user-ok'],
|
|
);
|
|
});
|
|
|
|
it('a socket with NO userId is skipped (not a candidate, never emitted to)', async () => {
|
|
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
|
|
|
const okEmit = jest.fn();
|
|
const anonEmit = jest.fn();
|
|
fetchSockets.mockResolvedValue([
|
|
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
|
// Unauthenticated socket: no userId -> excluded from the candidate map.
|
|
{ id: 's2', data: {}, emit: anonEmit },
|
|
]);
|
|
|
|
const data = { operation: 'moveTreeNode' };
|
|
await service.emitTreeEvent('space-1', 'page-1', data);
|
|
|
|
expect(okEmit).toHaveBeenCalledWith('message', data);
|
|
expect(anonEmit).not.toHaveBeenCalled();
|
|
// The no-userId socket is not even offered as a candidate to the repo.
|
|
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
|
'page-1',
|
|
['user-ok'],
|
|
);
|
|
});
|
|
|
|
it('no sockets in the room -> no repo lookup, no emit', async () => {
|
|
fetchSockets.mockResolvedValue([]);
|
|
|
|
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
|
|
|
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes through the space room name', async () => {
|
|
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue([]);
|
|
fetchSockets.mockResolvedValue([
|
|
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
|
|
]);
|
|
|
|
await service.emitTreeEvent('space-7', 'page-1', { op: 'x' });
|
|
|
|
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
|
|
});
|
|
});
|