test(server): batch 5 authorization, transclusion, search & comment coverage

Test-only. Fills the authorization / data-integrity gaps from the strategy
report. Full server suite: 100 suites / 1031 passed + 1 todo, green.

Authorization (privilege-escalation catches):
- workspace/space ability factories: exact can/cannot per (action,subject) —
  admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc.
- findHighestUserSpaceRole, isAdminActingOnOwner.
- WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target.
- SpaceMemberService.validateLastAdmin: never orphan a space without an admin.
- GroupService: default-group immutability, name uniqueness.

Access / data integrity:
- PageAccessService: restriction-vs-space-ability branches for view/edit/comment.
- TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts
  NO attachment write or ref-row delete on rejection; lookupWithAccessSet
  positional status mapping; listReferences drops private/cross-ws/deleted refs;
  syncPageTransclusions/References diff (no-op on unchanged content).
- SearchService.searchPage: query-mode scoping; leakage modes return empty
  before executing the query.
- CommentService: reply-to-reply guard, agent provenance, self-mention filter,
  no double-notify.

Pure helpers:
- prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID
  validation, removeMarkTypeFromDoc), collaboration.util (getPageId,
  isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement).

Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 18:40:07 +03:00
parent 0b2af34029
commit 4df79aafd3
18 changed files with 3846 additions and 0 deletions

View 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');
});
});

View File

@@ -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,
);
});
});
});

View File

@@ -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);
});
});
});

View 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: [],
});
});
});
});

View 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);
});
});

View 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');
});
});

View File

@@ -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();
});
});

View File

@@ -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',
});
});
});

View File

@@ -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']);
});
});

View File

@@ -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' },
]);
});
});

View File

@@ -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',
);
});
});

View 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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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);
});
});