test(public-share): cover getSharePage positive + soft-deleted branches (#85)
The anonymous share page-fetch tool's positive branch (sanitize via updatePublicAttachments then jsonToMarkdown before returning to the model) was untested, so a dropped/reordered sanitizer would ship a comment-mark/raw- attachment leak with green tests. Add a positive-branch test pinning the sanitizer call + that markdown derives from sanitized content, and a soft-deleted test asserting a generic error with no content fetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user