test(server): batch 5 authorization, transclusion, search & comment coverage
Test-only. Fills the authorization / data-integrity gaps from the strategy report. Full server suite: 100 suites / 1031 passed + 1 todo, green. Authorization (privilege-escalation catches): - workspace/space ability factories: exact can/cannot per (action,subject) — admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc. - findHighestUserSpaceRole, isAdminActingOnOwner. - WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target. - SpaceMemberService.validateLastAdmin: never orphan a space without an admin. - GroupService: default-group immutability, name uniqueness. Access / data integrity: - PageAccessService: restriction-vs-space-ability branches for view/edit/comment. - TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts NO attachment write or ref-row delete on rejection; lookupWithAccessSet positional status mapping; listReferences drops private/cross-ws/deleted refs; syncPageTransclusions/References diff (no-op on unchanged content). - SearchService.searchPage: query-mode scoping; leakage modes return empty before executing the query. - CommentService: reply-to-reply guard, agent provenance, self-mention filter, no double-notify. Pure helpers: - prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID validation, removeMarkTypeFromDoc), collaboration.util (getPageId, isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement). Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { SpaceMemberService } from './space-member.service';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
|
||||
// Direct-instantiation unit tests for SpaceMemberService.validateLastAdmin,
|
||||
// exercised through its two real call sites: removeMemberFromSpace and
|
||||
// updateSpaceMemberRole. The guard is what prevents a space from being orphaned
|
||||
// with no admin (full-access) member. Tests assert both the thrown exception
|
||||
// type AND that no destructive repo write fired on a rejection.
|
||||
//
|
||||
// Constructor arg order (7 positional deps) is pinned: spaceMemberRepo,
|
||||
// groupUserRepo, spaceRepo, watcherRepo, favoriteRepo, db, auditService.
|
||||
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
const SPACE_ID = 'space-1';
|
||||
|
||||
function buildService(opts?: {
|
||||
space?: any;
|
||||
member?: any;
|
||||
adminCount?: number;
|
||||
}) {
|
||||
const spaceRepo = {
|
||||
// Default: a real space so the NotFound(space) guard is not what fires.
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
opts?.space === undefined ? { id: SPACE_ID, name: 'Space 1' } : opts.space,
|
||||
),
|
||||
};
|
||||
|
||||
const spaceMemberRepo = {
|
||||
getSpaceMemberByTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts?.member ?? null),
|
||||
roleCountBySpaceId: jest.fn().mockResolvedValue(opts?.adminCount ?? 2),
|
||||
removeSpaceMemberById: jest.fn().mockResolvedValue(undefined),
|
||||
updateSpaceMember: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const groupUserRepo = {
|
||||
getUserIdsByGroupId: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const watcherRepo = {
|
||||
deleteByUsersWithoutSpaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const favoriteRepo = {
|
||||
deleteByUsersWithoutSpaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// db.transaction().execute(cb) just runs the callback with a noop trx.
|
||||
const db = {
|
||||
transaction: jest.fn().mockReturnValue({
|
||||
execute: jest.fn(async (cb: any) => cb({} as any)),
|
||||
}),
|
||||
};
|
||||
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const service = new SpaceMemberService(
|
||||
spaceMemberRepo as any, // spaceMemberRepo
|
||||
groupUserRepo as any, // groupUserRepo
|
||||
spaceRepo as any, // spaceRepo
|
||||
watcherRepo as any, // watcherRepo
|
||||
favoriteRepo as any, // favoriteRepo
|
||||
db as any, // db
|
||||
auditService as any, // auditService
|
||||
);
|
||||
|
||||
return { service, spaceMemberRepo, spaceRepo, auditService };
|
||||
}
|
||||
|
||||
describe('SpaceMemberService.removeMemberFromSpace last-admin guard', () => {
|
||||
it('rejects removing the only ADMIN member with BadRequest (no removal)', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({
|
||||
member: { id: 'sm-1', role: SpaceRole.ADMIN, userId: 'u-1' },
|
||||
adminCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removeMemberFromSpace(
|
||||
{ spaceId: SPACE_ID, userId: 'u-1' } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(spaceMemberRepo.removeSpaceMemberById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes an ADMIN member when more than one admin exists', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({
|
||||
member: { id: 'sm-1', role: SpaceRole.ADMIN, userId: 'u-1' },
|
||||
adminCount: 2,
|
||||
});
|
||||
|
||||
await service.removeMemberFromSpace(
|
||||
{ spaceId: SPACE_ID, userId: 'u-1' } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(spaceMemberRepo.removeSpaceMemberById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removing a non-admin member skips the last-admin check entirely', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({
|
||||
member: { id: 'sm-2', role: SpaceRole.WRITER, userId: 'u-2' },
|
||||
adminCount: 1, // even at 1 admin, the check must not run for a non-admin
|
||||
});
|
||||
|
||||
await service.removeMemberFromSpace(
|
||||
{ spaceId: SPACE_ID, userId: 'u-2' } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(spaceMemberRepo.roleCountBySpaceId).not.toHaveBeenCalled();
|
||||
expect(spaceMemberRepo.removeSpaceMemberById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects with BadRequest when neither userId nor groupId is provided', async () => {
|
||||
const { service, spaceMemberRepo } = buildService();
|
||||
|
||||
await expect(
|
||||
service.removeMemberFromSpace(
|
||||
{ spaceId: SPACE_ID } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(spaceMemberRepo.getSpaceMemberByTypeId).not.toHaveBeenCalled();
|
||||
expect(spaceMemberRepo.removeSpaceMemberById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects with NotFound when the membership does not exist', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({ member: null });
|
||||
|
||||
await expect(
|
||||
service.removeMemberFromSpace(
|
||||
{ spaceId: SPACE_ID, userId: 'u-missing' } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(spaceMemberRepo.removeSpaceMemberById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpaceMemberService.updateSpaceMemberRole last-admin guard', () => {
|
||||
it('rejects demoting the only ADMIN with BadRequest (no update)', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({
|
||||
member: { id: 'sm-1', role: SpaceRole.ADMIN, userId: 'u-1' },
|
||||
adminCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateSpaceMemberRole(
|
||||
{ spaceId: SPACE_ID, userId: 'u-1', role: SpaceRole.WRITER } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(spaceMemberRepo.updateSpaceMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows demoting an ADMIN when more than one admin exists', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({
|
||||
member: { id: 'sm-1', role: SpaceRole.ADMIN, userId: 'u-1' },
|
||||
adminCount: 2,
|
||||
});
|
||||
|
||||
await service.updateSpaceMemberRole(
|
||||
{ spaceId: SPACE_ID, userId: 'u-1', role: SpaceRole.WRITER } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(spaceMemberRepo.updateSpaceMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns early when the role is unchanged (no admin check, no update)', async () => {
|
||||
const { service, spaceMemberRepo, auditService } = buildService({
|
||||
member: { id: 'sm-1', role: SpaceRole.ADMIN, userId: 'u-1' },
|
||||
adminCount: 1, // would otherwise trip the guard, but the no-op returns first
|
||||
});
|
||||
|
||||
await service.updateSpaceMemberRole(
|
||||
{ spaceId: SPACE_ID, userId: 'u-1', role: SpaceRole.ADMIN } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(spaceMemberRepo.roleCountBySpaceId).not.toHaveBeenCalled();
|
||||
expect(spaceMemberRepo.updateSpaceMember).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('promoting a non-admin (WRITER->ADMIN) skips the last-admin check', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({
|
||||
member: { id: 'sm-2', role: SpaceRole.WRITER, userId: 'u-2' },
|
||||
adminCount: 1,
|
||||
});
|
||||
|
||||
await service.updateSpaceMemberRole(
|
||||
{ spaceId: SPACE_ID, userId: 'u-2', role: SpaceRole.ADMIN } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(spaceMemberRepo.roleCountBySpaceId).not.toHaveBeenCalled();
|
||||
expect(spaceMemberRepo.updateSpaceMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects with NotFound when the membership does not exist', async () => {
|
||||
const { service, spaceMemberRepo } = buildService({ member: null });
|
||||
|
||||
await expect(
|
||||
service.updateSpaceMemberRole(
|
||||
{ spaceId: SPACE_ID, userId: 'u-missing', role: SpaceRole.WRITER } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(spaceMemberRepo.updateSpaceMember).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user