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:
309
apps/server/src/common/helpers/prosemirror/extractors.spec.ts
Normal file
309
apps/server/src/common/helpers/prosemirror/extractors.spec.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
extractUserMentionIdsFromJson,
|
||||
getAttachmentIds,
|
||||
extractMentions,
|
||||
extractUserMentions,
|
||||
extractPageMentions,
|
||||
removeMarkTypeFromDoc,
|
||||
} from './utils';
|
||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||
|
||||
// Real UUIDs (uuid.validate must accept these).
|
||||
const UUID_A = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const UUID_B = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
const UUID_C = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
// Helper builders that mirror the real ProseMirror JSON shapes.
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const paragraph = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
const mention = (attrs: Record<string, any>) => ({ type: 'mention', attrs });
|
||||
|
||||
describe('extractUserMentionIdsFromJson', () => {
|
||||
it('collects entityIds for user mentions only', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
mention({ entityType: 'user', entityId: UUID_B }),
|
||||
),
|
||||
);
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A, UUID_B]);
|
||||
});
|
||||
|
||||
it('dedups the same entityId', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
),
|
||||
);
|
||||
// Mutation guard: a non-dedup impl would return [UUID_A, UUID_A].
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
|
||||
expect(extractUserMentionIdsFromJson(json)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters OUT non-user entityTypes (page mentions ignored)', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'page', entityId: UUID_A }),
|
||||
mention({ entityType: 'user', entityId: UUID_B }),
|
||||
),
|
||||
);
|
||||
// Cross-contamination guard: page mention must not leak in.
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_B]);
|
||||
});
|
||||
|
||||
it('skips a user mention with no entityId', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'user' }),
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
),
|
||||
);
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
|
||||
});
|
||||
|
||||
it('returns [] for null / undefined node', () => {
|
||||
expect(extractUserMentionIdsFromJson(null)).toEqual([]);
|
||||
expect(extractUserMentionIdsFromJson(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles a mention node with missing attrs without throwing', () => {
|
||||
const json = doc(paragraph({ type: 'mention' }));
|
||||
expect(() => extractUserMentionIdsFromJson(json)).not.toThrow();
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([]);
|
||||
});
|
||||
|
||||
it('walks deeply nested content', () => {
|
||||
const json = doc(
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
paragraph(mention({ entityType: 'user', entityId: UUID_A })),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachmentIds', () => {
|
||||
it('collects attachmentIds from image, video and attachment nodes', () => {
|
||||
const json = doc(
|
||||
{ type: 'image', attrs: { src: 'a', attachmentId: UUID_A } },
|
||||
{ type: 'video', attrs: { src: 'b', attachmentId: UUID_B } },
|
||||
{
|
||||
type: 'attachment',
|
||||
attrs: {
|
||||
url: 'c',
|
||||
name: 'file',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1,
|
||||
attachmentId: UUID_C,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(getAttachmentIds(json).sort()).toEqual(
|
||||
[UUID_A, UUID_B, UUID_C].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips an invalid (non-UUID) attachmentId', () => {
|
||||
const json = doc(
|
||||
{ type: 'image', attrs: { src: 'a', attachmentId: 'not-a-uuid' } },
|
||||
{ type: 'image', attrs: { src: 'b', attachmentId: UUID_A } },
|
||||
);
|
||||
// Guard: a non-UUID must never leak into downstream queries.
|
||||
expect(getAttachmentIds(json)).toEqual([UUID_A]);
|
||||
});
|
||||
|
||||
it('dedups the same attachmentId across nodes', () => {
|
||||
const json = doc(
|
||||
{ type: 'image', attrs: { src: 'a', attachmentId: UUID_A } },
|
||||
{ type: 'image', attrs: { src: 'b', attachmentId: UUID_A } },
|
||||
);
|
||||
expect(getAttachmentIds(json)).toEqual([UUID_A]);
|
||||
});
|
||||
|
||||
it('ignores non-attachment node types', () => {
|
||||
const json = doc(
|
||||
paragraph({ type: 'text', text: 'hi' }),
|
||||
// A paragraph carrying an attachmentId-like attr must NOT be collected.
|
||||
{ ...paragraph(), attrs: { attachmentId: UUID_A } },
|
||||
);
|
||||
expect(getAttachmentIds(json)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for an empty doc with no attachments', () => {
|
||||
expect(getAttachmentIds(doc(paragraph()))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMentions / extractUserMentions / extractPageMentions', () => {
|
||||
it('extractMentions dedups by id (NOT by entityId)', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
// Same id, different label -> must be dropped as a duplicate.
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'Alice again',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
// Different id but SAME entityId -> must be KEPT (dedup key is id).
|
||||
mention({
|
||||
id: 'mention-2',
|
||||
label: 'Alice elsewhere',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = extractMentions(json);
|
||||
// Dedup key footgun: if it deduped by entityId we'd only get 1.
|
||||
expect(result.map((m) => m.id)).toEqual(['mention-1', 'mention-2']);
|
||||
});
|
||||
|
||||
it('extractMentions skips a mention missing id', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ label: 'no id', entityType: 'user', entityId: UUID_A }),
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'has id',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = extractMentions(json);
|
||||
expect(result.map((m) => m.id)).toEqual(['mention-1']);
|
||||
});
|
||||
|
||||
it('extractMentions preserves the full mention shape', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'Bob',
|
||||
entityType: 'user',
|
||||
entityId: UUID_B,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [m] = extractMentions(json);
|
||||
expect(m).toMatchObject({
|
||||
id: 'mention-1',
|
||||
label: 'Bob',
|
||||
entityType: 'user',
|
||||
entityId: UUID_B,
|
||||
creatorId: UUID_C,
|
||||
});
|
||||
});
|
||||
|
||||
it('extractUserMentions keeps only entityType === user', () => {
|
||||
const list = [
|
||||
{ id: '1', label: 'u', entityType: 'user', entityId: UUID_A, creatorId: 'c' },
|
||||
{ id: '2', label: 'p', entityType: 'page', entityId: UUID_B, creatorId: 'c' },
|
||||
] as any;
|
||||
const users = extractUserMentions(list);
|
||||
expect(users.map((m) => m.id)).toEqual(['1']);
|
||||
expect(users.every((m) => m.entityType === 'user')).toBe(true);
|
||||
});
|
||||
|
||||
it('extractPageMentions dedups by entityId and filters to page', () => {
|
||||
const list = [
|
||||
{ id: 'a', label: 'p', entityType: 'page', entityId: UUID_A, creatorId: 'c' },
|
||||
// Same entityId, different id -> must be dropped (dedup key is entityId).
|
||||
{ id: 'b', label: 'p2', entityType: 'page', entityId: UUID_A, creatorId: 'c' },
|
||||
// A user mention that happens to share the entityId -> filtered out.
|
||||
{ id: 'c', label: 'u', entityType: 'user', entityId: UUID_A, creatorId: 'c' },
|
||||
{ id: 'd', label: 'p3', entityType: 'page', entityId: UUID_B, creatorId: 'c' },
|
||||
] as any;
|
||||
const pages = extractPageMentions(list);
|
||||
// Dedup key footgun: dedup is by entityId here, not by id.
|
||||
expect(pages.map((m) => m.entityId)).toEqual([UUID_A, UUID_B]);
|
||||
expect(pages.map((m) => m.id)).toEqual(['a', 'd']);
|
||||
expect(pages.every((m) => m.entityType === 'page')).toBe(true);
|
||||
});
|
||||
|
||||
it('extractUserMentions / extractPageMentions return [] for an empty list', () => {
|
||||
expect(extractUserMentions([])).toEqual([]);
|
||||
expect(extractPageMentions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMarkTypeFromDoc', () => {
|
||||
it('removes the named mark across the whole doc', () => {
|
||||
const node = jsonToNode(
|
||||
doc(
|
||||
paragraph({ type: 'text', text: 'first', marks: [{ type: 'bold' }] }),
|
||||
paragraph({ type: 'text', text: 'second', marks: [{ type: 'bold' }] }),
|
||||
),
|
||||
);
|
||||
const result = removeMarkTypeFromDoc(node, 'bold');
|
||||
// No text node anywhere should still carry marks after removal.
|
||||
const json = result.toJSON();
|
||||
const marksLeft: any[] = [];
|
||||
result.descendants((n) => {
|
||||
if (n.marks.length > 0) marksLeft.push(n.marks);
|
||||
});
|
||||
expect(marksLeft).toEqual([]);
|
||||
expect(JSON.stringify(json)).not.toContain('"type":"bold"');
|
||||
// Text content survives, only the mark is gone.
|
||||
expect(result.textContent).toBe('firstsecond');
|
||||
});
|
||||
|
||||
it('leaves other marks intact when removing one mark type', () => {
|
||||
const node = jsonToNode(
|
||||
doc(
|
||||
paragraph({
|
||||
type: 'text',
|
||||
text: 'styled',
|
||||
marks: [{ type: 'bold' }, { type: 'italic' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = removeMarkTypeFromDoc(node, 'bold');
|
||||
const serialized = JSON.stringify(result.toJSON());
|
||||
expect(serialized).not.toContain('"bold"');
|
||||
expect(serialized).toContain('"italic"');
|
||||
});
|
||||
|
||||
it('returns the doc unchanged (no throw) for an unknown mark name', () => {
|
||||
const node = jsonToNode(
|
||||
doc(paragraph({ type: 'text', text: 'x', marks: [{ type: 'bold' }] })),
|
||||
);
|
||||
let result!: ReturnType<typeof removeMarkTypeFromDoc>;
|
||||
// Guard: the `!markType` branch must short-circuit, never throw.
|
||||
expect(() => {
|
||||
result = removeMarkTypeFromDoc(node, 'noSuchMarkAnywhere');
|
||||
}).not.toThrow();
|
||||
// Returns the SAME node reference (no transform applied).
|
||||
expect(result).toBe(node);
|
||||
expect(JSON.stringify(result.toJSON())).toContain('"bold"');
|
||||
});
|
||||
|
||||
it('is a no-op on a doc that has no marks', () => {
|
||||
const node = jsonToNode(
|
||||
doc(paragraph({ type: 'text', text: 'plain' })),
|
||||
);
|
||||
const result = removeMarkTypeFromDoc(node, 'bold');
|
||||
expect(result.textContent).toBe('plain');
|
||||
expect(JSON.stringify(result.toJSON())).not.toContain('marks');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user