diff --git a/apps/server/src/collaboration/collaboration.util.spec.ts b/apps/server/src/collaboration/collaboration.util.spec.ts new file mode 100644 index 00000000..a0e0c4d4 --- /dev/null +++ b/apps/server/src/collaboration/collaboration.util.spec.ts @@ -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." 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'); + }); +}); diff --git a/apps/server/src/common/helpers/prosemirror/extractors.spec.ts b/apps/server/src/common/helpers/prosemirror/extractors.spec.ts new file mode 100644 index 00000000..3f8596e8 --- /dev/null +++ b/apps/server/src/common/helpers/prosemirror/extractors.spec.ts @@ -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) => ({ 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; + // 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'); + }); +}); diff --git a/apps/server/src/core/attachment/attachment.utils.spec.ts b/apps/server/src/core/attachment/attachment.utils.spec.ts new file mode 100644 index 00000000..ef25aa20 --- /dev/null +++ b/apps/server/src/core/attachment/attachment.utils.spec.ts @@ -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 /avatars', () => { + expect(getAttachmentFolderPath(AttachmentType.Avatar, WORKSPACE)).toBe( + `${WORKSPACE}/avatars`, + ); + }); + + it('maps WorkspaceIcon to /workspace-logos', () => { + expect( + getAttachmentFolderPath(AttachmentType.WorkspaceIcon, WORKSPACE), + ).toBe(`${WORKSPACE}/workspace-logos`); + }); + + it('maps SpaceIcon to /space-logos', () => { + expect(getAttachmentFolderPath(AttachmentType.SpaceIcon, WORKSPACE)).toBe( + `${WORKSPACE}/space-logos`, + ); + }); + + it('maps File to /files', () => { + expect(getAttachmentFolderPath(AttachmentType.File, WORKSPACE)).toBe( + `${WORKSPACE}/files`, + ); + }); + + it('maps Chat to /chat-files', () => { + expect(getAttachmentFolderPath(AttachmentType.Chat, WORKSPACE)).toBe( + `${WORKSPACE}/chat-files`, + ); + }); + + it('falls back to /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.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'); + }); +}); diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.spec.ts b/apps/server/src/core/casl/abilities/space-ability.factory.spec.ts new file mode 100644 index 00000000..d34d92ec --- /dev/null +++ b/apps/server/src/core/casl/abilities/space-ability.factory.spec.ts @@ -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, + ); + }); + }); +}); diff --git a/apps/server/src/core/casl/abilities/workspace-ability.factory.spec.ts b/apps/server/src/core/casl/abilities/workspace-ability.factory.spec.ts new file mode 100644 index 00000000..1e67ad3f --- /dev/null +++ b/apps/server/src/core/casl/abilities/workspace-ability.factory.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/server/src/core/comment/comment.service.behavior.spec.ts b/apps/server/src/core/comment/comment.service.behavior.spec.ts new file mode 100644 index 00000000..9ca0286a --- /dev/null +++ b/apps/server/src/core/comment/comment.service.behavior.spec.ts @@ -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 => ({ + id: 'page-1', + spaceId: 'space-1', + ...over, + }); + const user = (over?: Partial): 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: [], + }); + }); + }); +}); diff --git a/apps/server/src/core/group/services/group.service.guards.spec.ts b/apps/server/src/core/group/services/group.service.guards.spec.ts new file mode 100644 index 00000000..7f8f41ae --- /dev/null +++ b/apps/server/src/core/group/services/group.service.guards.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/core/label/utils.spec.ts b/apps/server/src/core/label/utils.spec.ts new file mode 100644 index 00000000..2f5b5284 --- /dev/null +++ b/apps/server/src/core/label/utils.spec.ts @@ -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'); + }); +}); diff --git a/apps/server/src/core/page/page-access/page-access.service.spec.ts b/apps/server/src/core/page/page-access/page-access.service.spec.ts new file mode 100644 index 00000000..44f9af99 --- /dev/null +++ b/apps/server/src/core/page/page-access/page-access.service.spec.ts @@ -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; + canUserEditPage?: () => Promise<{ + hasAnyRestriction: boolean; + canAccess: boolean; + canEdit: boolean; + }>; + canUserAccessPage?: () => Promise; + 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(); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/list-references.spec.ts b/apps/server/src/core/page/transclusion/spec/list-references.spec.ts new file mode 100644 index 00000000..6698a99d --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/list-references.spec.ts @@ -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) { + 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; +}) { + 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', + }); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/lookup-with-access-set.spec.ts b/apps/server/src/core/page/transclusion/spec/lookup-with-access-set.spec.ts new file mode 100644 index 00000000..8f775f7b --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/lookup-with-access-set.spec.ts @@ -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']); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/sync-page-transclusions.spec.ts b/apps/server/src/core/page/transclusion/spec/sync-page-transclusions.spec.ts new file mode 100644 index 00000000..96596a7a --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/sync-page-transclusions.spec.ts @@ -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: , content: { type: 'doc', 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) { + 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) { + 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' }, + ]); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/unsync-reference.spec.ts b/apps/server/src/core/page/transclusion/spec/unsync-reference.spec.ts new file mode 100644 index 00000000..8a0f7dbf --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/unsync-reference.spec.ts @@ -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; + 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 = {}) { + return { + id: 'ref-1', + workspaceId: USER_WORKSPACE, + spaceId: 'space-ref', + deletedAt: null, + ...overrides, + }; +} +function srcPage(overrides: Partial = {}) { + 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', + ); + }); +}); diff --git a/apps/server/src/core/search/search.service.query-mode.spec.ts b/apps/server/src/core/search/search.service.query-mode.spec.ts new file mode 100644 index 00000000..de1a5b38 --- /dev/null +++ b/apps/server/src/core/search/search.service.query-mode.spec.ts @@ -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', )`. 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(); + }); +}); diff --git a/apps/server/src/core/space/services/space-member.service.guards.spec.ts b/apps/server/src/core/space/services/space-member.service.guards.spec.ts new file mode 100644 index 00000000..cfb0b620 --- /dev/null +++ b/apps/server/src/core/space/services/space-member.service.guards.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/core/workspace/services/workspace.service.role-guards.spec.ts b/apps/server/src/core/workspace/services/workspace.service.role-guards.spec.ts new file mode 100644 index 00000000..a17ebae1 --- /dev/null +++ b/apps/server/src/core/workspace/services/workspace.service.role-guards.spec.ts @@ -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(); + }); +}); diff --git a/apps/server/src/core/workspace/workspace.util.spec.ts b/apps/server/src/core/workspace/workspace.util.spec.ts new file mode 100644 index 00000000..178e6f79 --- /dev/null +++ b/apps/server/src/core/workspace/workspace.util.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/database/repos/space/utils.spec.ts b/apps/server/src/database/repos/space/utils.spec.ts new file mode 100644 index 00000000..cb5add3f --- /dev/null +++ b/apps/server/src/database/repos/space/utils.spec.ts @@ -0,0 +1,58 @@ +import { findHighestUserSpaceRole } from './utils'; +import { SpaceRole } from '../../../common/helpers/types/permission'; +import { UserSpaceRole } from './types'; + +// Pins the space-role precedence used by SpaceAbilityFactory: ADMIN (3) > +// WRITER (2) > READER (1). A precedence inversion would let a writer/reader be +// resolved as the highest role and silently gain admin/writer abilities, so we +// assert the exact winning role for mixed inputs regardless of array order. + +const role = (r: SpaceRole): UserSpaceRole => ({ userId: 'u1', role: r }); + +describe('findHighestUserSpaceRole', () => { + it('returns ADMIN as the highest among reader, admin, writer', () => { + const roles = [ + role(SpaceRole.READER), + role(SpaceRole.ADMIN), + role(SpaceRole.WRITER), + ]; + + expect(findHighestUserSpaceRole(roles)).toBe(SpaceRole.ADMIN); + }); + + it('returns WRITER over READER', () => { + const roles = [role(SpaceRole.READER), role(SpaceRole.WRITER)]; + + expect(findHighestUserSpaceRole(roles)).toBe(SpaceRole.WRITER); + }); + + it('is independent of array order (admin last still wins)', () => { + const roles = [role(SpaceRole.WRITER), role(SpaceRole.ADMIN)]; + + expect(findHighestUserSpaceRole(roles)).toBe(SpaceRole.ADMIN); + }); + + it('returns the only role when a single membership is present', () => { + expect(findHighestUserSpaceRole([role(SpaceRole.READER)])).toBe( + SpaceRole.READER, + ); + expect(findHighestUserSpaceRole([role(SpaceRole.WRITER)])).toBe( + SpaceRole.WRITER, + ); + expect(findHighestUserSpaceRole([role(SpaceRole.ADMIN)])).toBe( + SpaceRole.ADMIN, + ); + }); + + it('returns undefined for an empty array (no membership)', () => { + expect(findHighestUserSpaceRole([])).toBeUndefined(); + }); + + it('returns undefined when given null', () => { + expect(findHighestUserSpaceRole(null as any)).toBeUndefined(); + }); + + it('returns undefined when given undefined', () => { + expect(findHighestUserSpaceRole(undefined as any)).toBeUndefined(); + }); +});