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:
claude code agent 227
2026-06-21 03:28:58 +03:00
parent 40f68e95fb
commit 317fdb9424

View File

@@ -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();
});
});
});