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:
claude_code
2026-06-21 18:40:07 +03:00
parent 0b2af34029
commit 4df79aafd3
18 changed files with 3846 additions and 0 deletions
@@ -0,0 +1,129 @@
import { NotFoundException } from '@nestjs/common';
import SpaceAbilityFactory from './space-ability.factory';
import { SpaceRole } from '../../../common/helpers/types/permission';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../interfaces/space-ability.type';
// Pins the space-level RBAC encoded by SpaceAbilityFactory.createForUser.
// The factory derives the role from spaceMemberRepo.getUserSpaceRoles() — the
// ONLY async dependency — which returns an array of { userId, role }. We stub
// that single repo call and run the REAL CASL builders so a writer/reader
// escalation, or a non-member gaining reader rights, flips an assertion.
const Manage = SpaceCaslAction.Manage;
const Read = SpaceCaslAction.Read;
const { Settings, Member, Page, Share } = SpaceCaslSubject;
// Build a factory whose getUserSpaceRoles resolves to the given roles array.
function factoryReturning(roles: Array<{ userId: string; role: string }>) {
const getUserSpaceRoles = jest.fn().mockResolvedValue(roles);
const spaceMemberRepo = { getUserSpaceRoles } as any;
return {
factory: new SpaceAbilityFactory(spaceMemberRepo),
getUserSpaceRoles,
};
}
const user = { id: 'u1' } as any;
const spaceId = 's1';
describe('SpaceAbilityFactory.createForUser', () => {
it('passes the user id and space id through to the repo lookup', async () => {
const { factory, getUserSpaceRoles } = factoryReturning([
{ userId: 'u1', role: SpaceRole.ADMIN },
]);
await factory.createForUser(user, spaceId);
expect(getUserSpaceRoles).toHaveBeenCalledWith('u1', 's1');
});
describe('ADMIN', () => {
it('can Manage Settings, Member, Page and Share', async () => {
const { factory } = factoryReturning([
{ userId: 'u1', role: SpaceRole.ADMIN },
]);
const ability = await factory.createForUser(user, spaceId);
expect(ability.can(Manage, Settings)).toBe(true);
expect(ability.can(Manage, Member)).toBe(true);
expect(ability.can(Manage, Page)).toBe(true);
expect(ability.can(Manage, Share)).toBe(true);
});
});
describe('WRITER', () => {
it('can Manage Page and Share', async () => {
const { factory } = factoryReturning([
{ userId: 'u1', role: SpaceRole.WRITER },
]);
const ability = await factory.createForUser(user, spaceId);
expect(ability.can(Manage, Page)).toBe(true);
expect(ability.can(Manage, Share)).toBe(true);
});
it('can only Read Settings and Member, never Manage them', async () => {
const { factory } = factoryReturning([
{ userId: 'u1', role: SpaceRole.WRITER },
]);
const ability = await factory.createForUser(user, spaceId);
expect(ability.can(Read, Settings)).toBe(true);
expect(ability.can(Read, Member)).toBe(true);
expect(ability.can(Manage, Settings)).toBe(false);
expect(ability.can(Manage, Member)).toBe(false);
});
});
describe('READER', () => {
it('can Read every subject', async () => {
const { factory } = factoryReturning([
{ userId: 'u1', role: SpaceRole.READER },
]);
const ability = await factory.createForUser(user, spaceId);
expect(ability.can(Read, Settings)).toBe(true);
expect(ability.can(Read, Member)).toBe(true);
expect(ability.can(Read, Page)).toBe(true);
expect(ability.can(Read, Share)).toBe(true);
});
it('canNOT Manage anything (read-only, no page or share writes)', async () => {
const { factory } = factoryReturning([
{ userId: 'u1', role: SpaceRole.READER },
]);
const ability = await factory.createForUser(user, spaceId);
expect(ability.can(Manage, Settings)).toBe(false);
expect(ability.can(Manage, Member)).toBe(false);
expect(ability.can(Manage, Page)).toBe(false);
expect(ability.can(Manage, Share)).toBe(false);
});
});
describe('no membership', () => {
it('throws NotFoundException when the roles array is empty', async () => {
const { factory } = factoryReturning([]);
await expect(factory.createForUser(user, spaceId)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('throws NotFoundException when the repo returns no roles (null)', async () => {
const { factory } = factoryReturning(null as any);
await expect(factory.createForUser(user, spaceId)).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
});
@@ -0,0 +1,113 @@
import { NotFoundException } from '@nestjs/common';
import WorkspaceAbilityFactory from './workspace-ability.factory';
import { UserRole } from '../../../common/helpers/types/permission';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../interfaces/workspace-ability.type';
// Pins the workspace-level RBAC encoded by WorkspaceAbilityFactory.createForUser.
// The role arrives via the `user.role` field (a UserRole enum value); the
// workspace argument is unused by the factory, so a bare stub is enough.
//
// The CASL builders are synchronous; we exercise the REAL factory and assert on
// the resulting ability with can()/cannot() so a privilege-escalation regression
// (admin gaining audit, member gaining write access) flips an assertion.
const factory = new WorkspaceAbilityFactory();
const workspace = { id: 'w1' } as any;
const abilityFor = (role: UserRole) =>
factory.createForUser({ id: 'u1', role } as any, workspace);
const Manage = WorkspaceCaslAction.Manage;
const Read = WorkspaceCaslAction.Read;
const Create = WorkspaceCaslAction.Create;
const { Settings, Member, Space, Group, Attachment, API, Audit } =
WorkspaceCaslSubject;
describe('WorkspaceAbilityFactory.createForUser', () => {
describe('OWNER', () => {
it('can Manage Audit (owner-only capability)', () => {
expect(abilityFor(UserRole.OWNER).can(Manage, Audit)).toBe(true);
});
it('can Manage Settings, Member, Space and Group', () => {
const ability = abilityFor(UserRole.OWNER);
expect(ability.can(Manage, Settings)).toBe(true);
expect(ability.can(Manage, Member)).toBe(true);
expect(ability.can(Manage, Space)).toBe(true);
expect(ability.can(Manage, Group)).toBe(true);
});
});
describe('ADMIN', () => {
it('canNOT Manage Audit (audit is owner-only)', () => {
const ability = abilityFor(UserRole.ADMIN);
expect(ability.can(Manage, Audit)).toBe(false);
expect(ability.cannot(Manage, Audit)).toBe(true);
});
it('canNOT Read Audit either (no audit ability at all)', () => {
expect(abilityFor(UserRole.ADMIN).can(Read, Audit)).toBe(false);
});
it('can Manage Settings, Member, Space and Group', () => {
const ability = abilityFor(UserRole.ADMIN);
expect(ability.can(Manage, Settings)).toBe(true);
expect(ability.can(Manage, Member)).toBe(true);
expect(ability.can(Manage, Space)).toBe(true);
expect(ability.can(Manage, Group)).toBe(true);
});
it('can Manage Attachment and API', () => {
const ability = abilityFor(UserRole.ADMIN);
expect(ability.can(Manage, Attachment)).toBe(true);
expect(ability.can(Manage, API)).toBe(true);
});
});
describe('MEMBER', () => {
it('can only Read Settings, Member, Space and Group', () => {
const ability = abilityFor(UserRole.MEMBER);
expect(ability.can(Read, Settings)).toBe(true);
expect(ability.can(Read, Member)).toBe(true);
expect(ability.can(Read, Space)).toBe(true);
expect(ability.can(Read, Group)).toBe(true);
});
it('canNOT Manage Settings, Member, Space or Group', () => {
const ability = abilityFor(UserRole.MEMBER);
expect(ability.can(Manage, Settings)).toBe(false);
expect(ability.can(Manage, Member)).toBe(false);
expect(ability.can(Manage, Space)).toBe(false);
expect(ability.can(Manage, Group)).toBe(false);
});
it('canNOT Manage Audit', () => {
expect(abilityFor(UserRole.MEMBER).can(Manage, Audit)).toBe(false);
});
it('keeps only the documented elevated grants (Manage Attachment, Create API)', () => {
const ability = abilityFor(UserRole.MEMBER);
// These are the deliberate exceptions to the read-only baseline.
expect(ability.can(Manage, Attachment)).toBe(true);
expect(ability.can(Create, API)).toBe(true);
// ...but a member must not gain blanket Manage over API.
expect(ability.can(Manage, API)).toBe(false);
});
});
describe('invalid role', () => {
it('throws NotFoundException for an unknown role string', () => {
expect(() =>
factory.createForUser({ id: 'u1', role: 'superuser' } as any, workspace),
).toThrow(NotFoundException);
});
it('throws NotFoundException when the role is undefined', () => {
expect(() =>
factory.createForUser({ id: 'u1', role: undefined } as any, workspace),
).toThrow(NotFoundException);
});
});
});