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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 18:40:07 +03:00
parent 0b2af34029
commit 4df79aafd3
18 changed files with 3846 additions and 0 deletions
@@ -0,0 +1,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: [],
});
});
});
});