diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts index dd46b527..1c3f6f8d 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts @@ -129,4 +129,112 @@ describe('PublicShareChatToolsService.forShare', () => { expect(shareService.getShareForPage).not.toHaveBeenCalled(); }); }); + + describe('getSharePage positive branch (security-relevant sanitization)', () => { + it('page belongs to THIS share, live, not restricted => sanitizes content (updatePublicAttachments) before jsonToMarkdown, returns {title, markdown} derived from SANITIZED content', async () => { + // The raw page content carries a comment mark + a raw attachment id that + // MUST NOT reach the anonymous model. updatePublicAttachments is the + // sanitizer that strips those; we assert the returned markdown is derived + // from its OUTPUT, never from the raw page.content. + const rawContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'SECRET_RAW_ATTACHMENT_ID_should_be_stripped', + marks: [{ type: 'comment', attrs: { commentId: 'c-1' } }], + }, + ], + }, + ], + }; + const sanitizedContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'sanitized public text' }], + }, + ], + }; + + const page = { + id: 'page-1', + title: 'Live Page', + deletedAt: null, + content: rawContent, + }; + + const { svc, shareService, pageRepo, pagePermissionRepo } = makeService({ + // getShareForPage resolves to THIS share (id matches the forShare scope). + getShareForPage: jest.fn().mockResolvedValue({ id: 'SHARE-A' }), + findById: jest.fn().mockResolvedValue(page), + }); + // Page has no restricted ancestor => passes the restriction gate. + pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false); + // The sanitizer returns the SANITIZED content (raw secrets removed). + shareService.updatePublicAttachments.mockResolvedValue(sanitizedContent); + + const tools = svc.forShare('SHARE-A', 'ws-1'); + const out = (await (tools.getSharePage as unknown as ToolExec).execute({ + pageId: ' page-1 ', + })) as { title: string; markdown: string }; + + // Membership + liveness + restriction checks were all consulted. + expect(shareService.getShareForPage).toHaveBeenCalledWith( + 'page-1', + 'ws-1', + ); + expect(pageRepo.findById).toHaveBeenCalledWith('page-1', { + includeContent: true, + }); + expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith( + 'page-1', + ); + + // CRITICAL: the sanitizer MUST be called with the page before any content + // is converted. If a future change drops/reorders this, raw comment marks + // and attachment ids would leak to the anonymous model. + expect(shareService.updatePublicAttachments).toHaveBeenCalledTimes(1); + expect(shareService.updatePublicAttachments).toHaveBeenCalledWith(page); + + // The returned markdown derives from the SANITIZED content, not the raw + // page.content: it contains the sanitized text and NONE of the secrets. + expect(out.title).toBe('Live Page'); + expect(out.markdown).toContain('sanitized public text'); + expect(out.markdown).not.toContain('SECRET_RAW_ATTACHMENT_ID'); + expect(out.markdown).not.toContain('commentId'); + }); + }); + + describe('getSharePage soft-deleted page', () => { + it('findById returns a soft-deleted page (deletedAt set) => generic error, NO content fetch (updatePublicAttachments not called, nothing leaked)', async () => { + const deletedPage = { + id: 'page-1', + title: 'Deleted Page', + deletedAt: new Date(), + content: { type: 'doc', content: [] }, + }; + const { svc, shareService, pagePermissionRepo } = makeService({ + getShareForPage: jest.fn().mockResolvedValue({ id: 'SHARE-A' }), + findById: jest.fn().mockResolvedValue(deletedPage), + }); + + const tools = svc.forShare('SHARE-A', 'ws-1'); + // Same generic message as an out-of-share page (no info leak). + await expect( + (tools.getSharePage as unknown as ToolExec).execute({ + pageId: 'page-1', + }), + ).rejects.toThrow('That page is not part of this published share.'); + + // Short-circuits before the restriction gate AND before the sanitizer: + // no content is ever fetched/returned for a soft-deleted page. + expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled(); + expect(shareService.updatePublicAttachments).not.toHaveBeenCalled(); + }); + }); });