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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user