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:
243
apps/server/src/collaboration/collaboration.util.spec.ts
Normal file
243
apps/server/src/collaboration/collaboration.util.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as Y from 'yjs';
|
||||
import {
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToNode,
|
||||
prosemirrorNodeToYElement,
|
||||
} from './collaboration.util';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
// Collect every node type name in a ProseMirror Node, in document order.
|
||||
const collectTypes = (node: Node): string[] => {
|
||||
const types: string[] = [];
|
||||
node.descendants((n) => {
|
||||
types.push(n.type.name);
|
||||
});
|
||||
return types;
|
||||
};
|
||||
|
||||
// Yjs types throw "Invalid access" until attached to a document, so every
|
||||
// produced Y element must be inserted into a fragment before it is inspected.
|
||||
const attach = (json: any): any => {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const element = prosemirrorNodeToYElement(json);
|
||||
fragment.insert(0, [element as any]);
|
||||
return element;
|
||||
};
|
||||
|
||||
describe('getPageId', () => {
|
||||
it('extracts the uuid from a "page.<uuid>" document name', () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(getPageId(`page.${uuid}`)).toBe(uuid);
|
||||
});
|
||||
|
||||
it('returns undefined when the name has no separator', () => {
|
||||
// Auth keying depends on this: a malformed name must not yield a stray id.
|
||||
expect(getPageId('justaname')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the second segment only, ignoring extra dotted parts', () => {
|
||||
expect(getPageId('page.abc.def')).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns an empty string for a trailing dot', () => {
|
||||
expect(getPageId('page.')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptyParagraphDoc', () => {
|
||||
it('returns true for a doc with a single empty paragraph', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({ type: 'doc', content: [{ type: 'paragraph' }] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a single paragraph with an empty content array', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [] }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a paragraph containing text', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a doc with more than one child', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }, { type: 'paragraph' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the single child is not a paragraph', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'heading', attrs: { level: 1 } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the root is not a "doc"', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({ type: 'paragraph', content: [] } as any),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null / undefined input', () => {
|
||||
expect(isEmptyParagraphDoc(null as any)).toBe(false);
|
||||
expect(isEmptyParagraphDoc(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnknownNodes (via jsonToNode fallback)', () => {
|
||||
it('drops an unknown leaf node while keeping known siblings', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
|
||||
{ type: 'totallyUnknownLeaf', attrs: {} },
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
// Only the paragraph + its text remain; the unknown leaf is gone.
|
||||
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
|
||||
expect(node.textContent).toBe('keep');
|
||||
});
|
||||
|
||||
it('unwraps an unknown WRAPPER, flattening its children (no content loss)', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'unknownWrapper',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'inside' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
// The wrapper disappears but its paragraph child is lifted up, not deleted.
|
||||
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
|
||||
expect(node.textContent).toBe('inside');
|
||||
});
|
||||
|
||||
it('leaves an entirely known document untouched', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: 'text', text: 'b' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
expect(collectTypes(node)).toEqual([
|
||||
'paragraph',
|
||||
'text',
|
||||
'heading',
|
||||
'text',
|
||||
]);
|
||||
expect(node.textContent).toBe('ab');
|
||||
});
|
||||
|
||||
it('drops an unknown inline nested inside a known node', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'weirdInline' },
|
||||
{ type: 'text', text: 'b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
// The unknown inline is silently removed; surrounding text survives.
|
||||
expect(node.textContent).toBe('ab');
|
||||
expect(collectTypes(node)).toEqual(['paragraph', 'text', 'text']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prosemirrorNodeToYElement', () => {
|
||||
it('produces a Y.XmlText carrying mark attrs as format on a marked text node', () => {
|
||||
const ytext = attach({
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
marks: [{ type: 'bold', attrs: { level: 2 } }, { type: 'italic' }],
|
||||
});
|
||||
const delta = ytext.toDelta();
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].insert).toBe('hi');
|
||||
// mark.attrs is used when present, otherwise `true` (the `|| true` path).
|
||||
expect(delta[0].attributes).toEqual({
|
||||
bold: { level: 2 },
|
||||
italic: true,
|
||||
});
|
||||
expect(ytext.length).toBe(2);
|
||||
});
|
||||
|
||||
it('produces a plain Y.XmlText with no format for an unmarked text node', () => {
|
||||
const ytext = attach({ type: 'text', text: 'plain' });
|
||||
const delta = ytext.toDelta();
|
||||
expect(delta).toEqual([{ insert: 'plain' }]);
|
||||
expect(ytext.length).toBe(5);
|
||||
});
|
||||
|
||||
it('sets element attributes, skipping null and undefined values', () => {
|
||||
const element = attach({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'left', indent: 0, anchorId: null, ghost: undefined },
|
||||
content: [{ type: 'text', text: 'abc' }],
|
||||
});
|
||||
expect(element.nodeName).toBe('paragraph');
|
||||
expect(element.getAttribute('textAlign')).toBe('left');
|
||||
// indent is 0 (falsy but defined) -> must still be set.
|
||||
expect(element.getAttribute('indent')).toBe(0);
|
||||
// null / undefined attrs are skipped, never set.
|
||||
expect(element.getAttribute('anchorId')).toBeUndefined();
|
||||
expect(element.getAttribute('ghost')).toBeUndefined();
|
||||
expect(element.getAttributes()).toEqual({ textAlign: 'left', indent: 0 });
|
||||
});
|
||||
|
||||
it('creates an element with no attributes when attrs is absent', () => {
|
||||
const element = attach({ type: 'horizontalRule' });
|
||||
expect(element.nodeName).toBe('horizontalRule');
|
||||
expect(element.getAttributes()).toEqual({});
|
||||
expect(element.length).toBe(0);
|
||||
});
|
||||
|
||||
it('recurses into nested content preserving order', () => {
|
||||
const element = attach({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
|
||||
],
|
||||
});
|
||||
// Two child paragraphs, in original order.
|
||||
expect(element.length).toBe(2);
|
||||
expect(element.get(0).get(0).toString()).toBe('one');
|
||||
expect(element.get(1).get(0).toString()).toBe('two');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user