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:
86
apps/server/src/core/attachment/attachment.utils.spec.ts
Normal file
86
apps/server/src/core/attachment/attachment.utils.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
getAttachmentFolderPath,
|
||||
validateFileType,
|
||||
} from './attachment.utils';
|
||||
import { AttachmentType } from './attachment.constants';
|
||||
|
||||
// Pins where each AttachmentType is stored and the file-type allow-list.
|
||||
// A wrong folder mapping would scatter uploads (e.g. avatars landing in /files),
|
||||
// and a broken validateFileType would let disallowed extensions bypass the
|
||||
// check, so we assert the exact path per type and the throw/no-throw behaviour.
|
||||
|
||||
const WORKSPACE = 'ws-123';
|
||||
|
||||
describe('getAttachmentFolderPath', () => {
|
||||
it('maps Avatar to <workspaceId>/avatars', () => {
|
||||
expect(getAttachmentFolderPath(AttachmentType.Avatar, WORKSPACE)).toBe(
|
||||
`${WORKSPACE}/avatars`,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps WorkspaceIcon to <workspaceId>/workspace-logos', () => {
|
||||
expect(
|
||||
getAttachmentFolderPath(AttachmentType.WorkspaceIcon, WORKSPACE),
|
||||
).toBe(`${WORKSPACE}/workspace-logos`);
|
||||
});
|
||||
|
||||
it('maps SpaceIcon to <workspaceId>/space-logos', () => {
|
||||
expect(getAttachmentFolderPath(AttachmentType.SpaceIcon, WORKSPACE)).toBe(
|
||||
`${WORKSPACE}/space-logos`,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps File to <workspaceId>/files', () => {
|
||||
expect(getAttachmentFolderPath(AttachmentType.File, WORKSPACE)).toBe(
|
||||
`${WORKSPACE}/files`,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps Chat to <workspaceId>/chat-files', () => {
|
||||
expect(getAttachmentFolderPath(AttachmentType.Chat, WORKSPACE)).toBe(
|
||||
`${WORKSPACE}/chat-files`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to <workspaceId>/files for an unknown type', () => {
|
||||
expect(
|
||||
getAttachmentFolderPath('totally-unknown' as AttachmentType, WORKSPACE),
|
||||
).toBe(`${WORKSPACE}/files`);
|
||||
});
|
||||
|
||||
it('covers every AttachmentType enum value with a non-fallback folder except File', () => {
|
||||
// Guards against a new AttachmentType silently inheriting the /files default.
|
||||
const expected: Record<AttachmentType, string> = {
|
||||
[AttachmentType.Avatar]: `${WORKSPACE}/avatars`,
|
||||
[AttachmentType.WorkspaceIcon]: `${WORKSPACE}/workspace-logos`,
|
||||
[AttachmentType.SpaceIcon]: `${WORKSPACE}/space-logos`,
|
||||
[AttachmentType.File]: `${WORKSPACE}/files`,
|
||||
[AttachmentType.Chat]: `${WORKSPACE}/chat-files`,
|
||||
};
|
||||
|
||||
for (const type of Object.values(AttachmentType)) {
|
||||
expect(getAttachmentFolderPath(type, WORKSPACE)).toBe(expected[type]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileType', () => {
|
||||
const allowed = ['.png', '.jpg', '.jpeg'];
|
||||
|
||||
it('does not throw when the extension is in the allow-list', () => {
|
||||
expect(() => validateFileType('.png', allowed)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws "Invalid file type" when the extension is not allowed', () => {
|
||||
expect(() => validateFileType('.exe', allowed)).toThrow('Invalid file type');
|
||||
});
|
||||
|
||||
it('is case-sensitive on the extension (uppercase is rejected)', () => {
|
||||
// The check uses Array.includes with no normalization, so ".PNG" !== ".png".
|
||||
expect(() => validateFileType('.PNG', allowed)).toThrow('Invalid file type');
|
||||
});
|
||||
|
||||
it('throws against an empty allow-list', () => {
|
||||
expect(() => validateFileType('.png', [])).toThrow('Invalid file type');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
377
apps/server/src/core/comment/comment.service.behavior.spec.ts
Normal file
377
apps/server/src/core/comment/comment.service.behavior.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { QueueJob } from '../../integrations/queue/constants';
|
||||
|
||||
/**
|
||||
* Behavioral coverage for CommentService (comment.service.ts):
|
||||
* - create() @53
|
||||
* - resolveComment() @223
|
||||
* - queueCommentNotification() @292 (exercised through create/update)
|
||||
*
|
||||
* The service is constructed directly with jest-mocked repos / gateway / queues
|
||||
* (the @InjectQueue tokens cannot be resolved by Test.createTestingModule — see
|
||||
* the existing comment.service.spec.ts note). Every async dep returns a resolved
|
||||
* promise so the real control flow runs end-to-end.
|
||||
*
|
||||
* These specs catch: the thread-depth invariant (no reply-to-a-reply, parent
|
||||
* must live on the same page), mis-attributed AI provenance (created_source /
|
||||
* resolved_source / ai_chat_id), and notification correctness (self-mention and
|
||||
* re-notify spam, plus missed reply / resolve notifications).
|
||||
*/
|
||||
describe('CommentService — behavior', () => {
|
||||
// ProseMirror-ish doc containing a single user mention. extractUserMentionIds
|
||||
// FromJson walks `content[]` for nodes of type 'mention' with
|
||||
// attrs.entityType==='user' and returns attrs.entityId.
|
||||
const docMentioning = (...entityIds: string[]) => ({
|
||||
type: 'doc',
|
||||
content: entityIds.map((entityId) => ({
|
||||
type: 'mention',
|
||||
attrs: { entityType: 'user', entityId },
|
||||
})),
|
||||
});
|
||||
|
||||
function makeService(overrides?: {
|
||||
insertedId?: string;
|
||||
parentComment?: any;
|
||||
}) {
|
||||
const insertedId = overrides?.insertedId ?? 'comment-new';
|
||||
|
||||
const commentRepo: any = {
|
||||
// findById is used both for parent lookup (create) and the post-write
|
||||
// re-read. Default: the parent lookup result is configurable; the re-read
|
||||
// returns a minimal hydrated comment carrying the inserted id.
|
||||
findById: jest.fn(async (id: string) => {
|
||||
if (
|
||||
overrides &&
|
||||
'parentComment' in overrides &&
|
||||
id === overrides.parentComment?.id
|
||||
) {
|
||||
return overrides.parentComment;
|
||||
}
|
||||
return { id, content: {}, spaceId: 'space-1', pageId: 'page-1' };
|
||||
}),
|
||||
insertComment: jest.fn(async () => ({ id: insertedId })),
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
};
|
||||
const pageRepo: any = {};
|
||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||
const collaborationGateway: any = {
|
||||
handleYjsEvent: jest.fn(async () => undefined),
|
||||
};
|
||||
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||
|
||||
const service = new CommentService(
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
commentRepo,
|
||||
wsService,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
};
|
||||
}
|
||||
|
||||
const page = (over?: Partial<any>): any => ({
|
||||
id: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
...over,
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
describe('create — thread-depth invariant & provenance', () => {
|
||||
it('rejects a reply whose parent is itself a reply: "You cannot reply to a reply"', async () => {
|
||||
const parentComment = {
|
||||
id: 'parent-1',
|
||||
pageId: 'page-1',
|
||||
// A non-null parentCommentId means the "parent" is already a reply.
|
||||
parentCommentId: 'grandparent-1',
|
||||
};
|
||||
const { service, commentRepo } = makeService({ parentComment });
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{
|
||||
content: JSON.stringify(docMentioning()),
|
||||
parentCommentId: 'parent-1',
|
||||
} as any,
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('You cannot reply to a reply'));
|
||||
|
||||
// The depth check happens before any write.
|
||||
expect(commentRepo.insertComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a reply when the parent lives on a different page: "Parent comment not found"', async () => {
|
||||
const parentComment = {
|
||||
id: 'parent-1',
|
||||
pageId: 'OTHER-page',
|
||||
parentCommentId: null,
|
||||
};
|
||||
const { service, commentRepo } = makeService({ parentComment });
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{
|
||||
content: JSON.stringify(docMentioning()),
|
||||
parentCommentId: 'parent-1',
|
||||
} as any,
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('Parent comment not found'));
|
||||
|
||||
expect(commentRepo.insertComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stamps createdSource:"agent" + aiChatId when the actor is an agent', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
{ actor: 'agent', aiChatId: 'chat-99' },
|
||||
);
|
||||
|
||||
const insertArg = commentRepo.insertComment.mock.calls[0][0];
|
||||
expect(insertArg.createdSource).toBe('agent');
|
||||
expect(insertArg.aiChatId).toBe('chat-99');
|
||||
// Provenance only annotates the source — the human stays the creator.
|
||||
expect(insertArg.creatorId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('leaves source default (no agent stamp) for a normal user', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user() },
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
// Normal user provenance.
|
||||
{ actor: 'user', aiChatId: null },
|
||||
);
|
||||
|
||||
const insertArg = commentRepo.insertComment.mock.calls[0][0];
|
||||
expect(insertArg).not.toHaveProperty('createdSource');
|
||||
expect(insertArg).not.toHaveProperty('aiChatId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveComment — provenance & resolve notifications', () => {
|
||||
it('stamps resolvedSource:"agent" when an agent resolves', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await service.resolveComment(comment, true, user({ id: 'user-1' }), {
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
const [patch] = commentRepo.updateComment.mock.calls[0];
|
||||
expect(patch.resolvedSource).toBe('agent');
|
||||
expect(patch.resolvedById).toBe('user-1');
|
||||
expect(patch.resolvedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('clears resolvedAt/resolvedById/resolvedSource to null on unresolve', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
// Unresolve as an agent — the agent marker must still clear, not persist.
|
||||
await service.resolveComment(comment, false, user({ id: 'user-2' }), {
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
const [patch] = commentRepo.updateComment.mock.calls[0];
|
||||
expect(patch).toEqual({
|
||||
resolvedAt: null,
|
||||
resolvedById: null,
|
||||
resolvedSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies the author when SOMEONE ELSE resolves their comment", async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'author-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await service.resolveComment(comment, true, user({ id: 'resolver-2' }));
|
||||
|
||||
expect(notificationQueue.add).toHaveBeenCalledTimes(1);
|
||||
const [jobName, jobData] = notificationQueue.add.mock.calls[0];
|
||||
expect(jobName).toBe(QueueJob.COMMENT_RESOLVED_NOTIFICATION);
|
||||
expect(jobData).toMatchObject({
|
||||
commentId: 'c-1',
|
||||
commentCreatorId: 'author-1',
|
||||
actorId: 'resolver-2',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT notify when resolving your OWN comment', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'self-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await service.resolveComment(comment, true, user({ id: 'self-1' }));
|
||||
|
||||
expect(notificationQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueCommentNotification — via create/update', () => {
|
||||
// Find the COMMENT_NOTIFICATION job among notificationQueue.add calls.
|
||||
const notifJob = (notificationQueue: any) =>
|
||||
notificationQueue.add.mock.calls.find(
|
||||
(c: any[]) => c[0] === QueueJob.COMMENT_NOTIFICATION,
|
||||
);
|
||||
|
||||
it('filters out a self-mention on create (no notification job)', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
// A brand-new top-level comment that mentions only its own author. The
|
||||
// self id is filtered, no watchers branch reachable here because the only
|
||||
// potential job is from the mention set... but create() passes
|
||||
// notifyWatchers=true for a top-level comment, so a job WILL fire — we
|
||||
// assert the self id was scrubbed from mentionedUserIds.
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user({ id: 'me' }) },
|
||||
{ content: JSON.stringify(docMentioning('me')) } as any,
|
||||
);
|
||||
|
||||
const job = notifJob(notificationQueue);
|
||||
expect(job).toBeDefined();
|
||||
// Self-mention must never appear in the recipients list.
|
||||
expect(job[1].mentionedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not re-notify an already-mentioned id on edit', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
// The comment already mentioned 'bob' (oldMentionIds). The edited content
|
||||
// mentions bob again plus nobody new, top-level (notifyWatchers=false on
|
||||
// update) → no new mentions, no watchers, no parent → NO job.
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'editor-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: docMentioning('bob'),
|
||||
};
|
||||
|
||||
await service.update(
|
||||
comment,
|
||||
{ content: JSON.stringify(docMentioning('bob')) } as any,
|
||||
user({ id: 'editor-1' }),
|
||||
);
|
||||
|
||||
expect(notifJob(notificationQueue)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enqueues newly added mentions on edit (re-notify guard does not over-suppress)', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'editor-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: docMentioning('bob'),
|
||||
};
|
||||
|
||||
// Edit adds 'carol' while keeping 'bob' → only 'carol' is new.
|
||||
await service.update(
|
||||
comment,
|
||||
{ content: JSON.stringify(docMentioning('bob', 'carol')) } as any,
|
||||
user({ id: 'editor-1' }),
|
||||
);
|
||||
|
||||
const job = notifJob(notificationQueue);
|
||||
expect(job).toBeDefined();
|
||||
expect(job[1].mentionedUserIds).toEqual(['carol']);
|
||||
});
|
||||
|
||||
it('enqueues NO job when no new mentions, not notifying watchers and no parent (edit)', async () => {
|
||||
const { service, notificationQueue } = makeService();
|
||||
|
||||
const comment: any = {
|
||||
id: 'c-1',
|
||||
creatorId: 'editor-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
content: docMentioning(),
|
||||
};
|
||||
|
||||
// Plain edit with no mentions at all: update() passes notifyWatchers=false
|
||||
// and no parentCommentId → the early return in queueCommentNotification.
|
||||
await service.update(
|
||||
comment,
|
||||
{ content: JSON.stringify(docMentioning()) } as any,
|
||||
user({ id: 'editor-1' }),
|
||||
);
|
||||
|
||||
expect(notifJob(notificationQueue)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enqueues a reply notification (parentCommentId) even with no new mentions', async () => {
|
||||
const parentComment = {
|
||||
id: 'parent-1',
|
||||
pageId: 'page-1',
|
||||
parentCommentId: null,
|
||||
};
|
||||
const { service, notificationQueue } = makeService({ parentComment });
|
||||
|
||||
// A reply with no mentions: notifyWatchers is false (!isReply) but the
|
||||
// parentCommentId keeps the job alive → reply notifications are not missed.
|
||||
await service.create(
|
||||
{ page: page(), workspaceId: 'ws-1', user: user({ id: 'replier' }) },
|
||||
{
|
||||
content: JSON.stringify(docMentioning()),
|
||||
parentCommentId: 'parent-1',
|
||||
} as any,
|
||||
);
|
||||
|
||||
const job = notifJob(notificationQueue);
|
||||
expect(job).toBeDefined();
|
||||
expect(job[1]).toMatchObject({
|
||||
parentCommentId: 'parent-1',
|
||||
notifyWatchers: false,
|
||||
mentionedUserIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
200
apps/server/src/core/group/services/group.service.guards.spec.ts
Normal file
200
apps/server/src/core/group/services/group.service.guards.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { GroupService } from './group.service';
|
||||
|
||||
// Direct-instantiation unit tests for GroupService's integrity guards:
|
||||
// - the DEFAULT (system) group cannot be updated or deleted;
|
||||
// - group names are unique on create and on rename;
|
||||
// - renaming a group to its OWN current name is allowed (no false positive).
|
||||
// Each rejection test also asserts that no destructive repo write fired.
|
||||
//
|
||||
// Constructor arg order (8 positional deps) is pinned: groupRepo, groupUserRepo,
|
||||
// spaceMemberRepo, groupUserService, watcherRepo, favoriteRepo, db,
|
||||
// auditService.
|
||||
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
|
||||
function buildService(opts?: {
|
||||
// group returned by groupRepo.findById (the target being updated/deleted)
|
||||
group?: any;
|
||||
// group returned by groupRepo.findByName (a name-collision probe)
|
||||
byName?: any;
|
||||
}) {
|
||||
const groupRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts?.group ?? null),
|
||||
findByName: jest.fn().mockResolvedValue(opts?.byName ?? null),
|
||||
insertGroup: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'g-new', name: 'New Group', description: null }),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const groupUserRepo = {
|
||||
getUserIdsByGroupId: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const spaceMemberRepo = {
|
||||
getSpaceIdsByGroupId: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const groupUserService = {
|
||||
addUsersToGroupBatch: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const watcherRepo = {
|
||||
deleteByUsersWithoutSpaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const favoriteRepo = {
|
||||
deleteByUsersWithoutSpaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const db = {
|
||||
transaction: jest.fn().mockReturnValue({
|
||||
execute: jest.fn(async (cb: any) => cb({} as any)),
|
||||
}),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const service = new GroupService(
|
||||
groupRepo as any, // groupRepo
|
||||
groupUserRepo as any, // groupUserRepo
|
||||
spaceMemberRepo as any, // spaceMemberRepo
|
||||
groupUserService as any, // groupUserService
|
||||
watcherRepo as any, // watcherRepo
|
||||
favoriteRepo as any, // favoriteRepo
|
||||
db as any, // db
|
||||
auditService as any, // auditService
|
||||
);
|
||||
|
||||
return { service, groupRepo, auditService };
|
||||
}
|
||||
|
||||
const authUser = { id: 'auth-1' } as any;
|
||||
|
||||
describe('GroupService.createGroup duplicate-name guard', () => {
|
||||
it('rejects creating a group with an existing name (no insert)', async () => {
|
||||
const { service, groupRepo } = buildService({
|
||||
byName: { id: 'g-existing', name: 'Engineering' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createGroup(authUser, WORKSPACE_ID, {
|
||||
name: 'Engineering',
|
||||
} as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(groupRepo.insertGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a group when the name is free', async () => {
|
||||
const { service, groupRepo } = buildService({ byName: null });
|
||||
|
||||
await service.createGroup(authUser, WORKSPACE_ID, {
|
||||
name: 'Engineering',
|
||||
} as any);
|
||||
|
||||
expect(groupRepo.insertGroup).toHaveBeenCalledTimes(1);
|
||||
// isDefault must always be false for a user-created group.
|
||||
expect(groupRepo.insertGroup.mock.calls[0][0]).toMatchObject({
|
||||
name: 'Engineering',
|
||||
isDefault: false,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupService.updateGroup guards', () => {
|
||||
it('rejects updating a DEFAULT group with BadRequest (no update)', async () => {
|
||||
const { service, groupRepo } = buildService({
|
||||
group: {
|
||||
id: 'g-default',
|
||||
name: 'Everyone',
|
||||
description: null,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateGroup(WORKSPACE_ID, {
|
||||
groupId: 'g-default',
|
||||
name: 'Renamed',
|
||||
} as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(groupRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects renaming to a name owned by a DIFFERENT group (no update)', async () => {
|
||||
const { service, groupRepo } = buildService({
|
||||
group: {
|
||||
id: 'g-1',
|
||||
name: 'Engineering',
|
||||
description: null,
|
||||
isDefault: false,
|
||||
},
|
||||
// A different group already holds the target name.
|
||||
byName: { id: 'g-2', name: 'Design' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateGroup(WORKSPACE_ID, {
|
||||
groupId: 'g-1',
|
||||
name: 'Design',
|
||||
} as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(groupRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows renaming a group to its OWN current name (no false collision)', async () => {
|
||||
// findByName returns the same group; group.name === existingGroup.name, so
|
||||
// the duplicate guard must NOT fire.
|
||||
const sameGroup = {
|
||||
id: 'g-1',
|
||||
name: 'Engineering',
|
||||
description: null,
|
||||
isDefault: false,
|
||||
};
|
||||
const { service, groupRepo } = buildService({
|
||||
group: { ...sameGroup },
|
||||
byName: { ...sameGroup },
|
||||
});
|
||||
|
||||
await service.updateGroup(WORKSPACE_ID, {
|
||||
groupId: 'g-1',
|
||||
name: 'Engineering',
|
||||
} as any);
|
||||
|
||||
expect(groupRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupService.deleteGroup guard', () => {
|
||||
it('rejects deleting a DEFAULT group with BadRequest (no delete)', async () => {
|
||||
const { service, groupRepo } = buildService({
|
||||
group: {
|
||||
id: 'g-default',
|
||||
name: 'Everyone',
|
||||
description: null,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deleteGroup('g-default', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(groupRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a non-default group', async () => {
|
||||
const { service, groupRepo } = buildService({
|
||||
group: {
|
||||
id: 'g-1',
|
||||
name: 'Engineering',
|
||||
description: null,
|
||||
isDefault: false,
|
||||
},
|
||||
});
|
||||
|
||||
await service.deleteGroup('g-1', WORKSPACE_ID);
|
||||
|
||||
expect(groupRepo.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
43
apps/server/src/core/label/utils.spec.ts
Normal file
43
apps/server/src/core/label/utils.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { normalizeLabelName } from './utils';
|
||||
|
||||
// Pins the server-side label normalizer used by the label repo/service/DTOs to
|
||||
// dedupe labels. Contract: trim the ends, collapse every run of whitespace into
|
||||
// a single hyphen, and lowercase. A regression here would let visually-identical
|
||||
// labels (differing only by case or spacing) be treated as distinct.
|
||||
|
||||
describe('normalizeLabelName', () => {
|
||||
it('lowercases the name', () => {
|
||||
expect(normalizeLabelName('Bug')).toBe('bug');
|
||||
expect(normalizeLabelName('HIGH-PRIORITY')).toBe('high-priority');
|
||||
});
|
||||
|
||||
it('trims leading and trailing whitespace', () => {
|
||||
expect(normalizeLabelName(' bug ')).toBe('bug');
|
||||
});
|
||||
|
||||
it('collapses an internal run of spaces into a single hyphen', () => {
|
||||
expect(normalizeLabelName('high priority')).toBe('high-priority');
|
||||
});
|
||||
|
||||
it('replaces a single internal space with a hyphen', () => {
|
||||
expect(normalizeLabelName('in progress')).toBe('in-progress');
|
||||
});
|
||||
|
||||
it('collapses tabs and newlines (any whitespace) into a single hyphen', () => {
|
||||
expect(normalizeLabelName('high\tpriority')).toBe('high-priority');
|
||||
expect(normalizeLabelName('high\npriority')).toBe('high-priority');
|
||||
expect(normalizeLabelName('high \t \n priority')).toBe('high-priority');
|
||||
});
|
||||
|
||||
it('collapses unicode whitespace (e.g. non-breaking space) into a hyphen', () => {
|
||||
expect(normalizeLabelName('high priority')).toBe('high-priority');
|
||||
});
|
||||
|
||||
it('applies trim, collapse and lowercase together', () => {
|
||||
expect(normalizeLabelName(' In PROGRESS\t ')).toBe('in-progress');
|
||||
});
|
||||
|
||||
it('leaves an already-normalized name unchanged', () => {
|
||||
expect(normalizeLabelName('high-priority')).toBe('high-priority');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { PageAccessService } from './page-access.service';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
|
||||
/**
|
||||
* Unit tests for PageAccessService — the privilege-escalation surface of the
|
||||
* page-access layer. The service is constructed directly with three jest-mocked
|
||||
* positional deps in the exact constructor order:
|
||||
*
|
||||
* new PageAccessService(pagePermissionRepo, spaceAbility, spaceRepo)
|
||||
*
|
||||
* The CASL ability returned by `spaceAbility.createForUser` is mocked as a plain
|
||||
* object exposing `can`/`cannot`. We drive `can`/`cannot` per (action, subject)
|
||||
* so the restriction-vs-space-level branch logic can be exercised precisely.
|
||||
*
|
||||
* The most dangerous bug class here is branch inversion: if `validateCanEdit`
|
||||
* reads the SPACE ability when the page is restricted (or vice versa), a viewer
|
||||
* could edit a restricted page, or a page-level writer could be blocked. The
|
||||
* tests below pin the EXACT source of the edit decision for each branch.
|
||||
*/
|
||||
|
||||
type AbilityDecision = (
|
||||
action: SpaceCaslAction,
|
||||
subject: SpaceCaslSubject,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Build a CASL-like ability stub. `decide` returns true when the user CAN do
|
||||
* (action, subject). `cannot` is the strict negation of `can`, matching CASL.
|
||||
*/
|
||||
function makeAbility(decide: AbilityDecision) {
|
||||
return {
|
||||
can: jest.fn((action: SpaceCaslAction, subject: SpaceCaslSubject) =>
|
||||
decide(action, subject),
|
||||
),
|
||||
cannot: jest.fn(
|
||||
(action: SpaceCaslAction, subject: SpaceCaslSubject) =>
|
||||
!decide(action, subject),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common "space member" ability: can Read pages, edit governed by `canEdit`.
|
||||
*/
|
||||
function memberAbility(canEdit: boolean) {
|
||||
return makeAbility((action, subject) => {
|
||||
if (subject !== SpaceCaslSubject.Page) return false;
|
||||
if (action === SpaceCaslAction.Read) return true;
|
||||
if (action === SpaceCaslAction.Edit) return canEdit;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Ability of a user who is NOT a space member: cannot even Read. */
|
||||
function nonMemberAbility() {
|
||||
return makeAbility(() => false);
|
||||
}
|
||||
|
||||
function buildService(opts: {
|
||||
ability: ReturnType<typeof makeAbility>;
|
||||
canUserEditPage?: () => Promise<{
|
||||
hasAnyRestriction: boolean;
|
||||
canAccess: boolean;
|
||||
canEdit: boolean;
|
||||
}>;
|
||||
canUserAccessPage?: () => Promise<boolean>;
|
||||
space?: unknown;
|
||||
}) {
|
||||
const pagePermissionRepo = {
|
||||
canUserEditPage: jest.fn(
|
||||
opts.canUserEditPage ??
|
||||
(async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
})),
|
||||
),
|
||||
canUserAccessPage: jest.fn(
|
||||
opts.canUserAccessPage ?? (async () => true),
|
||||
),
|
||||
};
|
||||
const spaceAbility = {
|
||||
createForUser: jest.fn().mockResolvedValue(opts.ability),
|
||||
};
|
||||
const spaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.space ?? null),
|
||||
};
|
||||
|
||||
const service = new PageAccessService(
|
||||
pagePermissionRepo as any,
|
||||
spaceAbility as any,
|
||||
spaceRepo as any,
|
||||
);
|
||||
return { service, pagePermissionRepo, spaceAbility, spaceRepo };
|
||||
}
|
||||
|
||||
const page = { id: 'page-1', spaceId: 'space-1' } as any;
|
||||
const user = { id: 'user-1' } as any;
|
||||
|
||||
describe('PageAccessService.validateCanEdit', () => {
|
||||
it('throws Forbidden when the user is not a space member (cannot Read)', async () => {
|
||||
const { service, pagePermissionRepo } = buildService({
|
||||
ability: nonMemberAbility(),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
// Must short-circuit before ever consulting page-level permissions.
|
||||
expect(pagePermissionRepo.canUserEditPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws Forbidden when page is restricted and page-level canEdit is false', async () => {
|
||||
// Restriction present -> the page-level writer flag governs. Even though the
|
||||
// space ability grants Edit, a restricted page without a writer grant blocks.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns {hasRestriction:true} when page is restricted and page-level canEdit is true', async () => {
|
||||
// Restricted + page-level writer grant. The SPACE ability denies Edit, but
|
||||
// the page-level grant must win — a branch inversion here would block a
|
||||
// legitimate page writer.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).resolves.toEqual({
|
||||
hasRestriction: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws Forbidden when page is unrestricted but the space ability denies Edit', async () => {
|
||||
// No restriction -> the space-level Edit decides. Space denies -> Forbidden,
|
||||
// even though page-level canEdit happens to be true (must be ignored here).
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns {hasRestriction:false} when page is unrestricted and the space allows Edit', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false, // ignored: unrestricted -> space ability governs
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).resolves.toEqual({
|
||||
hasRestriction: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageAccessService.validateCanViewWithPermissions', () => {
|
||||
it('throws Forbidden when restricted and canAccess is false', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('restricted+accessible: canEdit is taken from canUserEditPage (NOT the space ability)', async () => {
|
||||
// Space ability would say "can edit" — but because the page is restricted,
|
||||
// the repo's page-level canEdit (false here) must be returned instead.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: false, hasRestriction: true });
|
||||
});
|
||||
|
||||
it('restricted+accessible: surfaces page-level canEdit true', async () => {
|
||||
// Space ability denies Edit, but page-level writer grant must surface.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: true, hasRestriction: true });
|
||||
});
|
||||
|
||||
it('unrestricted: canEdit comes from the SPACE ability, not the repo', async () => {
|
||||
// hasAnyRestriction false -> the SPACE Edit ability decides. The repo's
|
||||
// canEdit (false) must be ignored; the space grant (true) must win.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: true, hasRestriction: false });
|
||||
});
|
||||
|
||||
it('unrestricted: space-denied Edit yields canEdit false even if repo says true', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true, // ignored
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: false, hasRestriction: false });
|
||||
});
|
||||
|
||||
it('throws Forbidden when the user is not a space member', async () => {
|
||||
const { service, pagePermissionRepo } = buildService({
|
||||
ability: nonMemberAbility(),
|
||||
});
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pagePermissionRepo.canUserEditPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageAccessService.validateCanComment', () => {
|
||||
it('returns immediately for an editor (validateCanEdit succeeds)', async () => {
|
||||
// Editor path: validateCanEdit resolves, so view/space-settings are never
|
||||
// consulted. allowViewerComments is irrelevant for an editor.
|
||||
const { service, spaceRepo, pagePermissionRepo } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).resolves.toBeUndefined();
|
||||
// No need to fall through to the space-settings viewer-comment gate.
|
||||
expect(spaceRepo.findById).not.toHaveBeenCalled();
|
||||
expect(pagePermissionRepo.canUserAccessPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes for a non-editor viewer when allowViewerComments is true', async () => {
|
||||
// Not an editor (space denies Edit, no restriction) but can view, and the
|
||||
// space setting allows viewer comments -> resolves.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => true,
|
||||
space: { settings: { comments: { allowViewerComments: true } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws Forbidden for a non-editor viewer when allowViewerComments is false', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => true,
|
||||
space: { settings: { comments: { allowViewerComments: false } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws Forbidden for a non-editor viewer when the setting is absent', async () => {
|
||||
// No comments settings at all (and a null space) -> the viewer-comment gate
|
||||
// is closed by default.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => true,
|
||||
space: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws Forbidden when the user cannot view (non-editor AND no view access)', async () => {
|
||||
// Not an editor, and validateCanView fails (canUserAccessPage false) -> the
|
||||
// viewer-comment branch is never reached; Forbidden from validateCanView.
|
||||
const { service, spaceRepo } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => false,
|
||||
space: { settings: { comments: { allowViewerComments: true } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// view check fails before we ever look at space settings.
|
||||
expect(spaceRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Tests for TransclusionService.listReferences — returns the source page info
|
||||
* plus the list of pages that reference a given sync block. This is a read path
|
||||
* that leaks title/icon/slug, so it MUST drop any referencing page the viewer
|
||||
* cannot see, any soft-deleted page, and any cross-workspace page — even if such
|
||||
* an id slipped through the referencePageIds filter.
|
||||
*
|
||||
* Collaborating methods/repos:
|
||||
* - pageTransclusionReferencesRepo.findReferencePageIdsByTransclusion(
|
||||
* sourcePageId, transclusionId, workspaceId) -> string[]
|
||||
* - filterViewerAccessiblePageIds(...) -> accessible ids (spied/stubbed)
|
||||
* - pageRepo.findById(id, { includeSpace: true }) -> page row (per id)
|
||||
*
|
||||
* Output ordering: `references` preserves the order of `referencePageIds`.
|
||||
* Catch: leaking title/icon of a private/cross-workspace referencing page.
|
||||
*/
|
||||
|
||||
const WS = 'w1';
|
||||
|
||||
function pageRow(over: Partial<any>) {
|
||||
return {
|
||||
id: 'x',
|
||||
slugId: 'slug-x',
|
||||
title: 'Title X',
|
||||
icon: '📄',
|
||||
spaceId: 'space-x',
|
||||
deletedAt: null,
|
||||
workspaceId: WS,
|
||||
space: { slug: 'space-slug-x' },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildService(opts: {
|
||||
referencePageIds: string[];
|
||||
accessibleIds: string[];
|
||||
pagesById: Record<string, any | null>;
|
||||
}) {
|
||||
const findReferencePageIdsByTransclusion = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.referencePageIds);
|
||||
const pageTransclusionReferencesRepo = {
|
||||
findReferencePageIdsByTransclusion,
|
||||
};
|
||||
const findById = jest.fn(async (id: string) => opts.pagesById[id] ?? null);
|
||||
const pageRepo = { findById };
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, 'filterViewerAccessiblePageIds')
|
||||
.mockResolvedValue(opts.accessibleIds);
|
||||
|
||||
return { service, findById, findReferencePageIdsByTransclusion };
|
||||
}
|
||||
|
||||
describe('TransclusionService.listReferences', () => {
|
||||
it('returns only accessible references; an inaccessible reference is excluded', async () => {
|
||||
// refs: pub (accessible) and priv (NOT accessible). source accessible too.
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['pub', 'priv'],
|
||||
accessibleIds: ['src', 'pub'], // priv missing -> filtered out
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src', slugId: 'src-slug', title: 'Src' }),
|
||||
pub: pageRow({ id: 'pub', slugId: 'pub-slug', title: 'Public ref' }),
|
||||
priv: pageRow({ id: 'priv', title: 'Private ref' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.source?.id).toBe('src');
|
||||
expect(result.references.map((r) => r.id)).toEqual(['pub']);
|
||||
// The private page's title must never appear.
|
||||
const json = JSON.stringify(result.references);
|
||||
expect(json).not.toContain('Private ref');
|
||||
});
|
||||
|
||||
it('drops a soft-deleted reference even though it passed the id filter', async () => {
|
||||
// "stale" is in referencePageIds AND in accessibleIds, but its page row is
|
||||
// soft-deleted -> must be dropped by the post-load workspace/deleted guard.
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['live', 'stale'],
|
||||
accessibleIds: ['src', 'live', 'stale'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
live: pageRow({ id: 'live', title: 'Live ref' }),
|
||||
stale: pageRow({ id: 'stale', title: 'Stale ref', deletedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.references.map((r) => r.id)).toEqual(['live']);
|
||||
expect(JSON.stringify(result.references)).not.toContain('Stale ref');
|
||||
});
|
||||
|
||||
it('drops a cross-workspace reference even though it passed the id filter', async () => {
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['mine', 'foreign'],
|
||||
accessibleIds: ['src', 'mine', 'foreign'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
mine: pageRow({ id: 'mine', title: 'Mine' }),
|
||||
foreign: pageRow({
|
||||
id: 'foreign',
|
||||
title: 'Foreign',
|
||||
workspaceId: 'other-ws',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.references.map((r) => r.id)).toEqual(['mine']);
|
||||
expect(JSON.stringify(result.references)).not.toContain('Foreign');
|
||||
});
|
||||
|
||||
it('returns source:null when the source is inaccessible but still lists accessible refs', async () => {
|
||||
// Viewer can see the referencing page but NOT the source page itself.
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['pub'],
|
||||
accessibleIds: ['pub'], // src not accessible
|
||||
pagesById: {
|
||||
pub: pageRow({ id: 'pub', title: 'Public ref' }),
|
||||
src: pageRow({ id: 'src', title: 'Hidden source' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.source).toBeNull();
|
||||
expect(result.references.map((r) => r.id)).toEqual(['pub']);
|
||||
});
|
||||
|
||||
it('short-circuits to {source:null, references:[]} when nothing is accessible', async () => {
|
||||
const { service, findById } = buildService({
|
||||
referencePageIds: ['a', 'b'],
|
||||
accessibleIds: [], // nothing accessible
|
||||
pagesById: {
|
||||
a: pageRow({ id: 'a' }),
|
||||
b: pageRow({ id: 'b' }),
|
||||
src: pageRow({ id: 'src' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ source: null, references: [] });
|
||||
// No page bodies loaded when the accessible set is empty.
|
||||
expect(findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the order of referencePageIds in the output', async () => {
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['c', 'a', 'b'],
|
||||
accessibleIds: ['src', 'a', 'b', 'c'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
a: pageRow({ id: 'a', title: 'A' }),
|
||||
b: pageRow({ id: 'b', title: 'B' }),
|
||||
c: pageRow({ id: 'c', title: 'C' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
// Output order must follow referencePageIds (c, a, b), NOT sorted/byId order.
|
||||
expect(result.references.map((r) => r.id)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('maps page fields and space slug into the reference info shape', async () => {
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['pub'],
|
||||
accessibleIds: ['src', 'pub'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
pub: pageRow({
|
||||
id: 'pub',
|
||||
slugId: 'pub-slug',
|
||||
title: 'Public',
|
||||
icon: '🔗',
|
||||
spaceId: 'space-pub',
|
||||
space: { slug: 'pub-space' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.references[0]).toEqual({
|
||||
id: 'pub',
|
||||
slugId: 'pub-slug',
|
||||
title: 'Public',
|
||||
icon: '🔗',
|
||||
spaceId: 'space-pub',
|
||||
spaceSlug: 'pub-space',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Tests for TransclusionService.lookupWithAccessSet — the positional resolver
|
||||
* that maps an ordered list of `(sourcePageId, transclusionId)` references onto
|
||||
* an output array of the SAME length and order. The caller supplies the set of
|
||||
* accessible source page ids; this method only resolves content for those, and
|
||||
* must never let one page's content surface under another page's slot.
|
||||
*
|
||||
* The two repos it touches:
|
||||
* - pageTransclusionsRepo.findManyByPageAndTransclusion(keys, workspaceId)
|
||||
* -> rows of { pageId, transclusionId, content }
|
||||
* - pageRepo.findManyByIds(ids, { workspaceId })
|
||||
* -> pages of { id, updatedAt } (used only for sourceUpdatedAt / not_found)
|
||||
*
|
||||
* Result statuses (transclusion.service.ts ~533):
|
||||
* - source not in accessibleSet -> 'no_access'
|
||||
* - accessible but page meta missing -> 'not_found'
|
||||
* - accessible + page present, row missing -> 'not_found'
|
||||
* - accessible + page present + row present-> { content, sourceUpdatedAt }
|
||||
*
|
||||
* Catch: positional misalignment leaking one page's content under another's
|
||||
* slot. We assert each output index carries the right sourcePageId/content.
|
||||
*/
|
||||
|
||||
const now = (n: number) => new Date(`2026-06-2${n}T00:00:00.000Z`);
|
||||
|
||||
function buildService(opts: {
|
||||
rows: Array<{ pageId: string; transclusionId: string; content: unknown }>;
|
||||
pages: Array<{ id: string; updatedAt: Date }>;
|
||||
}) {
|
||||
const findManyByPageAndTransclusion = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.rows);
|
||||
const findManyByIds = jest.fn().mockResolvedValue(opts.pages);
|
||||
|
||||
const pageTransclusionsRepo = { findManyByPageAndTransclusion };
|
||||
const pageRepo = { findManyByIds };
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
pageTransclusionsRepo as any,
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
return { service, findManyByPageAndTransclusion, findManyByIds };
|
||||
}
|
||||
|
||||
describe('TransclusionService.lookupWithAccessSet', () => {
|
||||
it('returns {items:[]} for empty references and queries nothing', async () => {
|
||||
const { service, findManyByPageAndTransclusion, findManyByIds } =
|
||||
buildService({ rows: [], pages: [] });
|
||||
|
||||
const result = await service.lookupWithAccessSet([], new Set(['p1']), 'w1');
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(findManyByPageAndTransclusion).not.toHaveBeenCalled();
|
||||
expect(findManyByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a source not in the accessibleSet as no_access', async () => {
|
||||
const { service } = buildService({ rows: [], pages: [] });
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'private', transclusionId: 't1' }],
|
||||
new Set(), // nothing accessible
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'private', transclusionId: 't1', status: 'no_access' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks an accessible page with no meta (missing/deleted) as not_found', async () => {
|
||||
// Accessible, but pageRepo returns no page row -> no updatedAt -> not_found.
|
||||
const { service } = buildService({ rows: [], pages: [] });
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'gone', transclusionId: 't1' }],
|
||||
new Set(['gone']),
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'gone', transclusionId: 't1', status: 'not_found' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accessible page present but no transclusion row -> not_found', async () => {
|
||||
const { service } = buildService({
|
||||
rows: [], // no matching transclusion row
|
||||
pages: [{ id: 'p1', updatedAt: now(0) }],
|
||||
});
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'p1', transclusionId: 't1' }],
|
||||
new Set(['p1']),
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'p1', transclusionId: 't1', status: 'not_found' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accessible + row present -> content with sourceUpdatedAt', async () => {
|
||||
const content = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const { service } = buildService({
|
||||
rows: [{ pageId: 'p1', transclusionId: 't1', content }],
|
||||
pages: [{ id: 'p1', updatedAt: now(0) }],
|
||||
});
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'p1', transclusionId: 't1' }],
|
||||
new Set(['p1']),
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't1',
|
||||
content,
|
||||
sourceUpdatedAt: now(0),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps positional alignment across a mixed batch (no cross-slot leakage)', async () => {
|
||||
// Order: [no_access, content(p2/t-a), not_found(no row), content(p3/t-b)]
|
||||
const cA = { type: 'doc', content: [{ type: 'text', text: 'A' }] };
|
||||
const cB = { type: 'doc', content: [{ type: 'text', text: 'B' }] };
|
||||
const { service } = buildService({
|
||||
rows: [
|
||||
{ pageId: 'p2', transclusionId: 't-a', content: cA },
|
||||
{ pageId: 'p3', transclusionId: 't-b', content: cB },
|
||||
],
|
||||
pages: [
|
||||
{ id: 'p2', updatedAt: now(1) },
|
||||
{ id: 'p3', updatedAt: now(2) },
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[
|
||||
{ sourcePageId: 'p1', transclusionId: 't-x' }, // not accessible
|
||||
{ sourcePageId: 'p2', transclusionId: 't-a' }, // content A
|
||||
{ sourcePageId: 'p2', transclusionId: 't-missing' }, // no row -> not_found
|
||||
{ sourcePageId: 'p3', transclusionId: 't-b' }, // content B
|
||||
],
|
||||
new Set(['p2', 'p3']),
|
||||
'w1',
|
||||
);
|
||||
|
||||
expect(items[0]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't-x',
|
||||
status: 'no_access',
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
sourcePageId: 'p2',
|
||||
transclusionId: 't-a',
|
||||
content: cA,
|
||||
sourceUpdatedAt: now(1),
|
||||
});
|
||||
expect(items[2]).toEqual({
|
||||
sourcePageId: 'p2',
|
||||
transclusionId: 't-missing',
|
||||
status: 'not_found',
|
||||
});
|
||||
expect(items[3]).toEqual({
|
||||
sourcePageId: 'p3',
|
||||
transclusionId: 't-b',
|
||||
content: cB,
|
||||
sourceUpdatedAt: now(2),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves duplicate (sourcePageId, transclusionId) references independently and keeps position', async () => {
|
||||
// The same ref appears twice; both slots must resolve to the same content,
|
||||
// and a DIFFERENT transclusionId on the same page must not bleed in.
|
||||
const cSame = { type: 'doc', content: [{ type: 'text', text: 'same' }] };
|
||||
const cOther = { type: 'doc', content: [{ type: 'text', text: 'other' }] };
|
||||
const { service } = buildService({
|
||||
rows: [
|
||||
{ pageId: 'p1', transclusionId: 't1', content: cSame },
|
||||
{ pageId: 'p1', transclusionId: 't2', content: cOther },
|
||||
],
|
||||
pages: [{ id: 'p1', updatedAt: now(3) }],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[
|
||||
{ sourcePageId: 'p1', transclusionId: 't1' },
|
||||
{ sourcePageId: 'p1', transclusionId: 't2' },
|
||||
{ sourcePageId: 'p1', transclusionId: 't1' }, // duplicate of slot 0
|
||||
],
|
||||
new Set(['p1']),
|
||||
'w1',
|
||||
);
|
||||
|
||||
expect(items[0]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't1',
|
||||
content: cSame,
|
||||
sourceUpdatedAt: now(3),
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't2',
|
||||
content: cOther,
|
||||
sourceUpdatedAt: now(3),
|
||||
});
|
||||
expect(items[2]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't1',
|
||||
content: cSame,
|
||||
sourceUpdatedAt: now(3),
|
||||
});
|
||||
});
|
||||
|
||||
it('only queries transclusions for accessible references', async () => {
|
||||
// The inaccessible page id must never appear in the repo key list — that
|
||||
// would itself be an existence-leak surface.
|
||||
const { service, findManyByPageAndTransclusion, findManyByIds } =
|
||||
buildService({
|
||||
rows: [{ pageId: 'ok', transclusionId: 't1', content: {} }],
|
||||
pages: [{ id: 'ok', updatedAt: now(0) }],
|
||||
});
|
||||
|
||||
await service.lookupWithAccessSet(
|
||||
[
|
||||
{ sourcePageId: 'secret', transclusionId: 'tz' },
|
||||
{ sourcePageId: 'ok', transclusionId: 't1' },
|
||||
],
|
||||
new Set(['ok']),
|
||||
'w1',
|
||||
);
|
||||
|
||||
const keys = findManyByPageAndTransclusion.mock.calls[0][0];
|
||||
expect(keys).toEqual([{ pageId: 'ok', transclusionId: 't1' }]);
|
||||
expect(findManyByPageAndTransclusion.mock.calls[0][1]).toBe('w1');
|
||||
expect(findManyByIds.mock.calls[0][0]).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Diff-logic tests for TransclusionService.syncPageTransclusions and
|
||||
* syncPageReferences. Both diff the desired state (parsed from PM JSON) against
|
||||
* the existing rows and issue only the minimal inserts/updates/deletes.
|
||||
*
|
||||
* The collector `collectTransclusionsFromPmJson` maps a `transclusionSource`
|
||||
* node to a snapshot of:
|
||||
* { transclusionId: <attrs.id>, content: { type: 'doc', content: <node.content ?? []> } }
|
||||
* So for the "unchanged -> no write" branch, the existing row's `content` must
|
||||
* deep-equal exactly that shape (isDeepStrictEqual). We mirror that here.
|
||||
*
|
||||
* Catch: spurious writes on unchanged content (the isDeepStrictEqual no-op
|
||||
* branch) and reference-sync drift (key must be `sourcePageId::transclusionId`,
|
||||
* so two refs differing only in transclusionId are distinct rows).
|
||||
*/
|
||||
|
||||
// Build a doc with one `transclusionSource` per (id, content-children) entry.
|
||||
function transclusionDoc(
|
||||
entries: Array<{ id: string; children?: unknown[] }>,
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: entries.map((e) => ({
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: e.id },
|
||||
content: e.children ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// The snapshot content shape the collector produces for the given children.
|
||||
function snapshotContent(children: unknown[] = []) {
|
||||
return { type: 'doc', content: children };
|
||||
}
|
||||
|
||||
function buildTransclusionService(existing: Array<any>) {
|
||||
const insert = jest.fn().mockResolvedValue(undefined);
|
||||
const update = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByPageAndTransclusionIds = jest.fn().mockResolvedValue(undefined);
|
||||
const findByPageId = jest.fn().mockResolvedValue(existing);
|
||||
|
||||
const pageTransclusionsRepo = {
|
||||
findByPageId,
|
||||
insert,
|
||||
update,
|
||||
deleteByPageAndTransclusionIds,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
pageTransclusionsRepo as any,
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
return { service, insert, update, deleteByPageAndTransclusionIds };
|
||||
}
|
||||
|
||||
describe('TransclusionService.syncPageTransclusions (diff logic)', () => {
|
||||
it('inserts a brand-new transclusion id', async () => {
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([{ id: 't-new', children: [{ type: 'paragraph' }] }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
|
||||
expect(insert).toHaveBeenCalledTimes(1);
|
||||
expect(insert.mock.calls[0][0]).toEqual({
|
||||
workspaceId: 'w1',
|
||||
pageId: 'page-1',
|
||||
transclusionId: 't-new',
|
||||
content: snapshotContent([{ type: 'paragraph' }]),
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates an existing id when its content changed (isDeepStrictEqual false)', async () => {
|
||||
const { service, insert, update } = buildTransclusionService([
|
||||
{ transclusionId: 't1', content: snapshotContent([{ type: 'old' }]) },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([{ id: 't1', children: [{ type: 'new' }] }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const [pageId, transclusionId, data] = update.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(transclusionId).toBe('t1');
|
||||
expect(data).toEqual({ content: snapshotContent([{ type: 'new' }]) });
|
||||
});
|
||||
|
||||
it('does NOT write when content is identical (no-op branch)', async () => {
|
||||
// The existing row content deep-equals the collector's snapshot exactly.
|
||||
const children = [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }];
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([
|
||||
{ transclusionId: 't1', content: snapshotContent(children) },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([{ id: 't1', children }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an existing id absent from the desired set', async () => {
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([
|
||||
{ transclusionId: 'gone', content: snapshotContent([]) },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([]), // nothing desired
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deleteByPageAndTransclusionIds).toHaveBeenCalledTimes(1);
|
||||
const [pageId, removedIds] = deleteByPageAndTransclusionIds.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(removedIds).toEqual(['gone']);
|
||||
});
|
||||
|
||||
it('handles a combined insert + update + no-op + delete in one pass', async () => {
|
||||
const same = [{ type: 'keep' }];
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([
|
||||
{ transclusionId: 'same', content: snapshotContent(same) }, // unchanged
|
||||
{ transclusionId: 'chg', content: snapshotContent([{ type: 'old' }]) }, // updated
|
||||
{ transclusionId: 'del', content: snapshotContent([]) }, // deleted
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([
|
||||
{ id: 'same', children: same }, // identical -> no write
|
||||
{ id: 'chg', children: [{ type: 'new' }] }, // changed -> update
|
||||
{ id: 'add', children: [{ type: 'fresh' }] }, // new -> insert
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, updated: 1, deleted: 1 });
|
||||
expect(insert).toHaveBeenCalledTimes(1);
|
||||
expect(insert.mock.calls[0][0].transclusionId).toBe('add');
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update.mock.calls[0][1]).toBe('chg');
|
||||
expect(deleteByPageAndTransclusionIds.mock.calls[0][1]).toEqual(['del']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// syncPageReferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function referenceDoc(
|
||||
refs: Array<{ sourcePageId: string; transclusionId: string }>,
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: refs.map((r) => ({
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: r.sourcePageId, transclusionId: r.transclusionId },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildReferenceService(existing: Array<any>) {
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndKeys = jest.fn().mockResolvedValue(undefined);
|
||||
const findByReferencePageId = jest.fn().mockResolvedValue(existing);
|
||||
|
||||
const pageTransclusionReferencesRepo = {
|
||||
findByReferencePageId,
|
||||
insertMany,
|
||||
deleteByReferenceAndKeys,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
return { service, insertMany, deleteByReferenceAndKeys };
|
||||
}
|
||||
|
||||
describe('TransclusionService.syncPageReferences (diff logic)', () => {
|
||||
it('inserts a new reference keyed by sourcePageId::transclusionId', async () => {
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([{ sourcePageId: 's1', transclusionId: 't1' }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 0 });
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{
|
||||
workspaceId: 'w1',
|
||||
referencePageId: 'ref-page',
|
||||
sourcePageId: 's1',
|
||||
transclusionId: 't1',
|
||||
},
|
||||
]);
|
||||
expect(deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an existing reference absent from the desired set', async () => {
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([
|
||||
{ sourcePageId: 's-gone', transclusionId: 't-gone' },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndKeys).toHaveBeenCalledTimes(1);
|
||||
const [referencePageId, keys] = deleteByReferenceAndKeys.mock.calls[0];
|
||||
expect(referencePageId).toBe('ref-page');
|
||||
expect(keys).toEqual([
|
||||
{ sourcePageId: 's-gone', transclusionId: 't-gone' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('no-ops when desired and existing already match', async () => {
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([{ sourcePageId: 's1', transclusionId: 't1' }]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([{ sourcePageId: 's1', transclusionId: 't1' }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats two refs differing only in transclusionId as DISTINCT keys', async () => {
|
||||
// existing has (s1,t1). desired keeps (s1,t1) and adds (s1,t2). The two must
|
||||
// not collapse: (s1,t2) is inserted, (s1,t1) untouched, nothing deleted.
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([{ sourcePageId: 's1', transclusionId: 't1' }]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([
|
||||
{ sourcePageId: 's1', transclusionId: 't1' },
|
||||
{ sourcePageId: 's1', transclusionId: 't2' },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 0 });
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{
|
||||
workspaceId: 'w1',
|
||||
referencePageId: 'ref-page',
|
||||
sourcePageId: 's1',
|
||||
transclusionId: 't2',
|
||||
},
|
||||
]);
|
||||
expect(deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('combines insert + delete when the source page of a ref changes', async () => {
|
||||
// existing (s-old,t1); desired (s-new,t1). Different sourcePageId -> distinct
|
||||
// key -> delete the old, insert the new.
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([{ sourcePageId: 's-old', transclusionId: 't1' }]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([{ sourcePageId: 's-new', transclusionId: 't1' }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 1 });
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{
|
||||
workspaceId: 'w1',
|
||||
referencePageId: 'ref-page',
|
||||
sourcePageId: 's-new',
|
||||
transclusionId: 't1',
|
||||
},
|
||||
]);
|
||||
expect(deleteByReferenceAndKeys.mock.calls[0][1]).toEqual([
|
||||
{ sourcePageId: 's-old', transclusionId: 't1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Permission-boundary tests for TransclusionService.unsyncReference.
|
||||
*
|
||||
* unsyncReference converts a `transclusionReference` into a self-contained copy
|
||||
* on the reference page: it copies attachments and deletes the reference row.
|
||||
* It is a write path that must NOT exfiltrate data across workspaces and must
|
||||
* NOT escalate privilege. These tests assert that every guard fires BEFORE any
|
||||
* attachment storage copy / attachment row insert / ref-row delete happens.
|
||||
*
|
||||
* Service is built with the 10 positional constructor args; only the deps each
|
||||
* test touches are real stubs. Real storage is never exercised: content used in
|
||||
* these tests has no attachment nodes, so the attachment-copy block is never
|
||||
* entered on the success-shaped paths, and guard paths throw before it.
|
||||
*
|
||||
* Source order of guards (transclusion.service.ts ~681):
|
||||
* 1. referencePage missing/soft-deleted -> NotFound('Reference page not found')
|
||||
* 2. sourcePage missing/soft-deleted -> NotFound('Source page not found')
|
||||
* 3. either page in a different workspace -> Forbidden
|
||||
* 4. validateCanEdit(referencePage) (may throw -> propagates)
|
||||
* 5. validateCanView(sourcePage)
|
||||
* 6. transclusion row missing -> NotFound('Sync block not found')
|
||||
*/
|
||||
|
||||
const USER_WORKSPACE = 'ws-user';
|
||||
|
||||
function buildService(opts: {
|
||||
pages?: Record<string, any>;
|
||||
validateCanEdit?: jest.Mock;
|
||||
validateCanView?: jest.Mock;
|
||||
transclusion?: any;
|
||||
}) {
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async (id: string) => opts.pages?.[id] ?? null),
|
||||
};
|
||||
const pageAccessService = {
|
||||
validateCanEdit:
|
||||
opts.validateCanEdit ?? jest.fn().mockResolvedValue({ hasRestriction: false }),
|
||||
validateCanView: opts.validateCanView ?? jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const pageTransclusionsRepo = {
|
||||
findByPageAndTransclusion: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.transclusion ?? null),
|
||||
};
|
||||
const pageTransclusionReferencesRepo = {
|
||||
deleteOne: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const attachmentRepo = {
|
||||
findByIds: jest.fn().mockResolvedValue([]),
|
||||
insertAttachment: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const storageService = {
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
pageTransclusionsRepo as any,
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
attachmentRepo as any,
|
||||
storageService as any,
|
||||
pageAccessService as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
pageTransclusionsRepo,
|
||||
pageTransclusionReferencesRepo,
|
||||
attachmentRepo,
|
||||
storageService,
|
||||
};
|
||||
}
|
||||
|
||||
const user = { id: 'user-1', workspaceId: USER_WORKSPACE } as any;
|
||||
|
||||
function refPage(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'ref-1',
|
||||
workspaceId: USER_WORKSPACE,
|
||||
spaceId: 'space-ref',
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function srcPage(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'src-1',
|
||||
workspaceId: USER_WORKSPACE,
|
||||
spaceId: 'space-src',
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TransclusionService.unsyncReference (permission boundary)', () => {
|
||||
it('reference page in a DIFFERENT workspace -> Forbidden before any write or delete', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage({ workspaceId: 'other-ws' }),
|
||||
'src-1': srcPage(),
|
||||
},
|
||||
transclusion: { content: { type: 'doc', content: [] } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
// No attachment copy, no attachment insert, no ref-row delete, and the
|
||||
// edit/view permission checks are never even reached.
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
expect(ctx.pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source page in a DIFFERENT workspace -> Forbidden before any write or delete', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage(),
|
||||
'src-1': srcPage({ workspaceId: 'other-ws' }),
|
||||
},
|
||||
transclusion: { content: { type: 'doc', content: [] } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
expect(ctx.pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reference page missing -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: { 'src-1': srcPage() }, // ref-1 absent
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reference page soft-deleted -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage({ deletedAt: new Date() }),
|
||||
'src-1': srcPage(),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source page missing -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage() }, // src-1 absent
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source page soft-deleted -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage(),
|
||||
'src-1': srcPage({ deletedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateCanEdit(referencePage) throws -> propagates; no attachment copy, ref row NOT deleted', async () => {
|
||||
const editError = new ForbiddenException('no edit');
|
||||
const validateCanEdit = jest.fn().mockRejectedValue(editError);
|
||||
const validateCanView = jest.fn().mockResolvedValue(undefined);
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage(), 'src-1': srcPage() },
|
||||
validateCanEdit,
|
||||
validateCanView,
|
||||
transclusion: { content: { type: 'doc', content: [] } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBe(editError);
|
||||
|
||||
// Edit check fires on the reference page (the write target).
|
||||
expect(validateCanEdit).toHaveBeenCalledTimes(1);
|
||||
expect(validateCanEdit.mock.calls[0][0].id).toBe('ref-1');
|
||||
// View on source never reached, no copy, no insert, no delete.
|
||||
expect(validateCanView).not.toHaveBeenCalled();
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('transclusion row missing -> NotFound("Sync block not found"); no delete', async () => {
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage(), 'src-1': srcPage() },
|
||||
transclusion: null, // findByPageAndTransclusion resolves null
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toMatchObject({ message: 'Sync block not found' });
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('happy path with no attachment nodes: deletes the ref row, copies nothing', async () => {
|
||||
// Sanity check that with all guards passing and content carrying no
|
||||
// attachment nodes, the ref row IS deleted and no storage copy happens.
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage(), 'src-1': srcPage() },
|
||||
transclusion: {
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ctx.service.unsyncReference(
|
||||
'ref-1',
|
||||
'src-1',
|
||||
't1',
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).toHaveBeenCalledWith(
|
||||
'ref-1',
|
||||
'src-1',
|
||||
't1',
|
||||
);
|
||||
});
|
||||
});
|
||||
222
apps/server/src/core/search/search.service.query-mode.spec.ts
Normal file
222
apps/server/src/core/search/search.service.query-mode.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/**
|
||||
* Coverage for SearchService.searchPage query-mode selection (search.service.ts
|
||||
* @25). searchPage chooses HOW the result set is scoped — by explicit space, by
|
||||
* the authenticated user's member spaces, or by a share — and must return an
|
||||
* empty set (without leaking data) for every disallowed combination.
|
||||
*
|
||||
* The kysely query builder is mocked with the same chainable pattern as the
|
||||
* existing search.service.spec.ts: every builder method returns the same builder
|
||||
* and `.execute()` resolves the supplied rows. Each `.where(...)` call is
|
||||
* recorded so we can assert exactly which scope clause was applied — that is the
|
||||
* mutation-resistant signal that distinguishes one query mode from another.
|
||||
*
|
||||
* These specs catch cross-space / cross-workspace search leakage and
|
||||
* share-scope bypass (data exposure).
|
||||
*/
|
||||
describe('SearchService.searchPage — query-mode selection', () => {
|
||||
// Build a chainable selectFrom('pages') builder that records its calls. The
|
||||
// builder is returned from `db.selectFrom` and is the single object every
|
||||
// chained call mutates/returns, mirroring the existing spec's pattern.
|
||||
function makeBuilder(rows: Array<{ id: string; highlight?: string }>) {
|
||||
const builder: any = {};
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.$if = jest.fn(() => builder);
|
||||
builder.orderBy = jest.fn(() => builder);
|
||||
builder.limit = jest.fn(() => builder);
|
||||
builder.offset = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () => rows);
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeService(opts?: {
|
||||
rows?: Array<{ id: string; highlight?: string }>;
|
||||
share?: any;
|
||||
isRestricted?: boolean;
|
||||
descendants?: Array<{ id: string }>;
|
||||
}) {
|
||||
const builder = makeBuilder(opts?.rows ?? []);
|
||||
|
||||
const db: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
};
|
||||
|
||||
// `getUserSpaceIdsQuery` returns a sub-query object that searchPage passes
|
||||
// straight into `.where('spaceId', 'in', <subquery>)`. A sentinel is enough
|
||||
// to assert the user-scoped branch was taken.
|
||||
const userSpaceIdsQuery = { __userSpaceIdsQuery: true };
|
||||
|
||||
const pageRepo = {
|
||||
// `.select((eb) => this.pageRepo.withSpace(eb))` — value ignored by stub.
|
||||
withSpace: jest.fn(() => ({ __withSpace: true })),
|
||||
getPageAndDescendantsExcludingRestricted: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts?.descendants ?? []),
|
||||
};
|
||||
const shareRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts?.share ?? null),
|
||||
};
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIdsQuery: jest.fn(() => userSpaceIdsQuery),
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts?.isRestricted ?? false),
|
||||
// Let everything through page-level permission filtering by default.
|
||||
filterAccessiblePageIds: jest
|
||||
.fn()
|
||||
.mockImplementation(async ({ pageIds }: { pageIds: string[] }) => pageIds),
|
||||
};
|
||||
|
||||
const service = new SearchService(
|
||||
db as any,
|
||||
pageRepo as any,
|
||||
shareRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
db,
|
||||
builder,
|
||||
pageRepo,
|
||||
shareRepo,
|
||||
spaceMemberRepo,
|
||||
pagePermissionRepo,
|
||||
userSpaceIdsQuery,
|
||||
};
|
||||
}
|
||||
|
||||
const whereCallFor = (builder: any, column: any) =>
|
||||
builder.where.mock.calls.find((c: any[]) => c[0] === column);
|
||||
|
||||
it('returns {items:[]} for a blank query WITHOUT touching the DB', async () => {
|
||||
const { service, db } = makeService();
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: '' } as any,
|
||||
{ userId: 'user-1', workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
// Blank query is rejected before any query builder is constructed.
|
||||
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scopes to the explicit spaceId branch', async () => {
|
||||
const { service, builder, db, spaceMemberRepo, shareRepo } = makeService({
|
||||
rows: [{ id: 'p-1' }],
|
||||
});
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan', spaceId: 'space-42' } as any,
|
||||
{ userId: 'user-1', workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(db.selectFrom).toHaveBeenCalledWith('pages');
|
||||
// The explicit-space branch adds exactly `.where('spaceId', '=', 'space-42')`.
|
||||
expect(whereCallFor(builder, 'spaceId')).toEqual([
|
||||
'spaceId',
|
||||
'=',
|
||||
'space-42',
|
||||
]);
|
||||
// It must NOT fall through to the user-member-spaces or share branch.
|
||||
expect(spaceMemberRepo.getUserSpaceIdsQuery).not.toHaveBeenCalled();
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
expect(result.items.map((i: any) => i.id)).toEqual(['p-1']);
|
||||
});
|
||||
|
||||
it('scopes an authenticated user WITHOUT spaceId to their member spaces', async () => {
|
||||
const { service, builder, spaceMemberRepo, userSpaceIdsQuery, shareRepo } =
|
||||
makeService({ rows: [{ id: 'p-9' }] });
|
||||
|
||||
await service.searchPage(
|
||||
{ query: 'plan' } as any,
|
||||
{ userId: 'user-7', workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
// The user-scoped branch resolves the member-spaces sub-query for that user
|
||||
// and restricts both spaceId (to that sub-query) and workspaceId.
|
||||
expect(spaceMemberRepo.getUserSpaceIdsQuery).toHaveBeenCalledWith('user-7');
|
||||
expect(whereCallFor(builder, 'spaceId')).toEqual([
|
||||
'spaceId',
|
||||
'in',
|
||||
userSpaceIdsQuery,
|
||||
]);
|
||||
expect(whereCallFor(builder, 'workspaceId')).toEqual([
|
||||
'workspaceId',
|
||||
'=',
|
||||
'ws-1',
|
||||
]);
|
||||
// Authenticated user path must not consult shares.
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns {items:[]} when the share belongs to a DIFFERENT workspace', async () => {
|
||||
const { service, builder, shareRepo, pagePermissionRepo } = makeService({
|
||||
share: {
|
||||
id: 'share-1',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'OTHER-ws',
|
||||
includeSubPages: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan', shareId: 'share-1' } as any,
|
||||
{ workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(shareRepo.findById).toHaveBeenCalledWith('share-1');
|
||||
expect(result).toEqual({ items: [] });
|
||||
// Workspace mismatch short-circuits before any restricted-ancestor / id
|
||||
// scoping or DB execution: no leak across workspaces.
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
|
||||
expect(builder.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns {items:[]} when the shared page has a restricted ancestor', async () => {
|
||||
const { service, builder, pagePermissionRepo, pageRepo } = makeService({
|
||||
share: {
|
||||
id: 'share-1',
|
||||
pageId: 'page-1',
|
||||
workspaceId: 'ws-1',
|
||||
includeSubPages: true,
|
||||
},
|
||||
isRestricted: true,
|
||||
});
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan', shareId: 'share-1' } as any,
|
||||
{ workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
);
|
||||
expect(result).toEqual({ items: [] });
|
||||
// Restricted ancestor must block before page enumeration and DB execution.
|
||||
expect(
|
||||
pageRepo.getPageAndDescendantsExcludingRestricted,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(builder.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns {items:[]} with no userId, no spaceId and no shareId', async () => {
|
||||
const { service, builder, shareRepo } = makeService();
|
||||
|
||||
const result = await service.searchPage(
|
||||
{ query: 'plan' } as any,
|
||||
{ workspaceId: 'ws-1' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
// The catch-all else returns empty without scoping/executing or hitting shares.
|
||||
expect(shareRepo.findById).not.toHaveBeenCalled();
|
||||
expect(builder.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
|
||||
// Direct-instantiation unit tests for the privilege/last-owner guards in
|
||||
// WorkspaceService.updateWorkspaceUserRole / deactivateUser / deleteUser.
|
||||
//
|
||||
// These guards are the membership-safety net: they stop an ADMIN from acting on
|
||||
// an OWNER, prevent the LAST owner from being demoted/removed (which would
|
||||
// orphan the workspace), and block a user from locking themselves out. Each
|
||||
// test constructs the service directly with jest-mocked repos (matching
|
||||
// page.service.spec.ts / workspace-update-gate.spec.ts) and asserts BOTH the
|
||||
// thrown exception AND that no destructive DB write happened on a rejection.
|
||||
//
|
||||
// Constructor arg order (18 positional deps) is pinned here so a reorder is
|
||||
// caught: workspaceRepo, spaceService, spaceMemberService, groupRepo,
|
||||
// groupUserRepo, userRepo, environmentService, domainService,
|
||||
// licenseCheckService, shareRepo, watcherRepo, favoriteRepo, db,
|
||||
// attachmentQueue, billingQueue, aiQueue, auditService, userSessionRepo.
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
role: UserRole | string;
|
||||
deletedAt?: Date | null;
|
||||
deactivatedAt?: Date | null;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
|
||||
function buildService(opts?: {
|
||||
target?: UserRow | null;
|
||||
ownerCount?: number;
|
||||
}) {
|
||||
// userRepo: findById resolves the target member; roleCountByWorkspaceId
|
||||
// returns how many OWNERs exist (drives the last-owner guard); updateUser is
|
||||
// the destructive write we assert is/ isn't called.
|
||||
const userRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts?.target ?? null),
|
||||
roleCountByWorkspaceId: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts?.ownerCount ?? 2),
|
||||
updateUser: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
// db.transaction().execute(cb) runs the callback with a fake trx. Only the
|
||||
// happy paths of deactivate/delete reach this; the guard-rejection tests
|
||||
// throw before it. The trx exposes deleteFrom(...).where(...).execute() and
|
||||
// updateTable(...).set(...).where(...).execute() chains used inside.
|
||||
const trxChain: any = {
|
||||
deleteFrom: jest.fn().mockReturnThis(),
|
||||
updateTable: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const db = {
|
||||
transaction: jest.fn().mockReturnValue({
|
||||
execute: jest.fn(async (cb: any) => cb(trxChain)),
|
||||
}),
|
||||
};
|
||||
|
||||
const userSessionRepo = {
|
||||
revokeByUserId: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const watcherRepo = {
|
||||
deleteByUserAndWorkspace: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const favoriteRepo = {
|
||||
deleteByUserAndWorkspace: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const attachmentQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const service = new WorkspaceService(
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // spaceService
|
||||
{} as any, // spaceMemberService
|
||||
{} as any, // groupRepo
|
||||
{} as any, // groupUserRepo
|
||||
userRepo as any, // userRepo
|
||||
{} as any, // environmentService
|
||||
{} as any, // domainService
|
||||
{} as any, // licenseCheckService
|
||||
{} as any, // shareRepo
|
||||
watcherRepo as any, // watcherRepo
|
||||
favoriteRepo as any, // favoriteRepo
|
||||
db as any, // db
|
||||
attachmentQueue as any, // attachmentQueue
|
||||
{} as any, // billingQueue
|
||||
{} as any, // aiQueue
|
||||
auditService as any, // auditService
|
||||
userSessionRepo as any, // userSessionRepo
|
||||
);
|
||||
|
||||
return { service, userRepo, auditService, db, userSessionRepo };
|
||||
}
|
||||
|
||||
const authUser = (role: UserRole, id = 'auth-1') =>
|
||||
({ id, role }) as any;
|
||||
|
||||
describe('WorkspaceService.updateWorkspaceUserRole role guards', () => {
|
||||
it('forbids an ADMIN acting on an OWNER target (no updateUser)', async () => {
|
||||
const { service, userRepo, auditService } = buildService({
|
||||
target: { id: 'u-target', role: UserRole.OWNER },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.ADMIN),
|
||||
{ userId: 'u-target', role: UserRole.MEMBER } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(userRepo.updateUser).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forbids an ADMIN promoting someone to OWNER (no updateUser)', async () => {
|
||||
const { service, userRepo } = buildService({
|
||||
target: { id: 'u-target', role: UserRole.MEMBER },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.ADMIN),
|
||||
{ userId: 'u-target', role: UserRole.OWNER } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(userRepo.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects demoting the LAST owner with BadRequest (no updateUser)', async () => {
|
||||
const { service, userRepo } = buildService({
|
||||
target: { id: 'u-target', role: UserRole.OWNER },
|
||||
ownerCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.OWNER),
|
||||
{ userId: 'u-target', role: UserRole.ADMIN } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userRepo.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows demoting an owner when more than one owner exists', async () => {
|
||||
const { service, userRepo, auditService } = buildService({
|
||||
target: { id: 'u-target', role: UserRole.OWNER },
|
||||
ownerCount: 2,
|
||||
});
|
||||
|
||||
await service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.OWNER),
|
||||
{ userId: 'u-target', role: UserRole.ADMIN } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(userRepo.updateUser).toHaveBeenCalledTimes(1);
|
||||
expect(userRepo.updateUser).toHaveBeenCalledWith(
|
||||
{ role: UserRole.ADMIN },
|
||||
'u-target',
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns early on a same-role no-op WITHOUT a DB write or audit', async () => {
|
||||
const { service, userRepo, auditService } = buildService({
|
||||
target: { id: 'u-target', role: UserRole.MEMBER },
|
||||
});
|
||||
|
||||
const result = await service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.OWNER),
|
||||
{ userId: 'u-target', role: UserRole.MEMBER } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
// Same-role early return hands back the loaded user untouched.
|
||||
expect(result).toEqual({ id: 'u-target', role: UserRole.MEMBER });
|
||||
expect(userRepo.updateUser).not.toHaveBeenCalled();
|
||||
expect(userRepo.roleCountByWorkspaceId).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('performs a valid MEMBER->ADMIN change: updateUser + audit', async () => {
|
||||
const { service, userRepo, auditService } = buildService({
|
||||
target: { id: 'u-target', role: UserRole.MEMBER },
|
||||
});
|
||||
|
||||
await service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.OWNER),
|
||||
{ userId: 'u-target', role: UserRole.ADMIN } as any,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(userRepo.updateUser).toHaveBeenCalledWith(
|
||||
{ role: UserRole.ADMIN },
|
||||
'u-target',
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects with BadRequest when the target member is not found', async () => {
|
||||
const { service, userRepo } = buildService({ target: null });
|
||||
|
||||
await expect(
|
||||
service.updateWorkspaceUserRole(
|
||||
authUser(UserRole.OWNER),
|
||||
{ userId: 'missing', role: UserRole.ADMIN } as any,
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userRepo.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService.deactivateUser guards', () => {
|
||||
it('rejects self-deactivation with BadRequest (no DB tx)', async () => {
|
||||
const { service, db } = buildService({
|
||||
target: { id: 'auth-1', role: UserRole.OWNER },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deactivateUser(authUser(UserRole.OWNER, 'auth-1'), 'auth-1', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an ADMIN deactivating an OWNER with BadRequest', async () => {
|
||||
const { service, db } = buildService({
|
||||
target: { id: 'u-owner', role: UserRole.OWNER },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deactivateUser(authUser(UserRole.ADMIN), 'u-owner', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deactivating the LAST owner with BadRequest', async () => {
|
||||
const { service, db } = buildService({
|
||||
target: { id: 'u-owner', role: UserRole.OWNER },
|
||||
ownerCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deactivateUser(authUser(UserRole.OWNER), 'u-owner', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deactivating an already-deactivated user with BadRequest', async () => {
|
||||
const { service, db } = buildService({
|
||||
target: {
|
||||
id: 'u-member',
|
||||
role: UserRole.MEMBER,
|
||||
deactivatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deactivateUser(authUser(UserRole.OWNER), 'u-member', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivates a normal member: writes deactivatedAt + revokes sessions', async () => {
|
||||
const { service, userRepo, userSessionRepo, db } = buildService({
|
||||
target: { id: 'u-member', role: UserRole.MEMBER },
|
||||
ownerCount: 2,
|
||||
});
|
||||
|
||||
await service.deactivateUser(
|
||||
authUser(UserRole.OWNER),
|
||||
'u-member',
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
|
||||
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(userRepo.updateUser).toHaveBeenCalledTimes(1);
|
||||
// The first positional arg is the patch object with a fresh deactivatedAt.
|
||||
expect(userRepo.updateUser.mock.calls[0][1]).toBe('u-member');
|
||||
expect(userRepo.updateUser.mock.calls[0][2]).toBe(WORKSPACE_ID);
|
||||
expect(userSessionRepo.revokeByUserId).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceService.deleteUser guards', () => {
|
||||
it('rejects deleting the LAST owner with BadRequest', async () => {
|
||||
const { service, db } = buildService({
|
||||
target: { id: 'u-owner', role: UserRole.OWNER },
|
||||
ownerCount: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deleteUser(authUser(UserRole.OWNER), 'u-owner', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects self-deletion with BadRequest', async () => {
|
||||
// Two owners exist so the last-owner guard does not fire first; the
|
||||
// self-target guard is what we are pinning here.
|
||||
const { service, db } = buildService({
|
||||
target: { id: 'auth-1', role: UserRole.OWNER },
|
||||
ownerCount: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deleteUser(authUser(UserRole.OWNER, 'auth-1'), 'auth-1', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an ADMIN deleting an OWNER with BadRequest', async () => {
|
||||
const { service, db } = buildService({
|
||||
target: { id: 'u-owner', role: UserRole.OWNER },
|
||||
ownerCount: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deleteUser(authUser(UserRole.ADMIN), 'u-owner', WORKSPACE_ID),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a normal member: anonymises + revokes sessions inside the tx', async () => {
|
||||
const { service, userRepo, userSessionRepo, db } = buildService({
|
||||
target: { id: 'u-member', role: UserRole.MEMBER },
|
||||
ownerCount: 2,
|
||||
});
|
||||
|
||||
await service.deleteUser(authUser(UserRole.OWNER), 'u-member', WORKSPACE_ID);
|
||||
|
||||
expect(db.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(userRepo.updateUser).toHaveBeenCalledTimes(1);
|
||||
expect(userRepo.updateUser.mock.calls[0][1]).toBe('u-member');
|
||||
expect(userSessionRepo.revokeByUserId).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
29
apps/server/src/core/workspace/workspace.util.spec.ts
Normal file
29
apps/server/src/core/workspace/workspace.util.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { isAdminActingOnOwner } from './workspace.util';
|
||||
import { UserRole } from '../../common/helpers/types/permission';
|
||||
|
||||
// Pins the guard that stops an admin from demoting/deleting an owner.
|
||||
// Signature: isAdminActingOnOwner(authUserRole, targetRole) — returns true ONLY
|
||||
// when an admin acts on an owner. Every other combination must be false, so we
|
||||
// assert the exact boolean for each pairing rather than mere truthiness.
|
||||
|
||||
describe('isAdminActingOnOwner', () => {
|
||||
it('returns true when an admin acts on an owner', () => {
|
||||
expect(isAdminActingOnOwner(UserRole.ADMIN, UserRole.OWNER)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when an owner acts on an owner', () => {
|
||||
expect(isAdminActingOnOwner(UserRole.OWNER, UserRole.OWNER)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when an admin acts on a member', () => {
|
||||
expect(isAdminActingOnOwner(UserRole.ADMIN, UserRole.MEMBER)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when an admin acts on another admin', () => {
|
||||
expect(isAdminActingOnOwner(UserRole.ADMIN, UserRole.ADMIN)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a member acts on an owner', () => {
|
||||
expect(isAdminActingOnOwner(UserRole.MEMBER, UserRole.OWNER)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user