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