3d4ad664b3
Batches 6-9: behaviour-preserving extractions of testable pure cores plus the tests they unblock, and a fix for the broken client test environment. Full suites green: server 113 suites / 1117 + 1 todo, client 30 files / 338. client (R0 infra): - vitest.setup.ts: in-memory localStorage/sessionStorage Storage stub wired via setupFiles. Unblocks menu-items.gating.test.ts (was 9 failing) -> client suite fully green. + menu-items.suggestions.test.ts (getSuggestionItems filter/sort). share: - extract buildShareMetaHtml (share-seo.util.ts) from the SEO controller; tests for reflected-XSS escaping in <title>/og/twitter meta, noindex, truncation; extractPageSlugId; updateAttachmentAttr; prepareContentForShare comment-strip (anonymous-viewer metadata-leak guard). ai-chat (security extractions): - selectAccessibleHits: CASL post-filter for semantic search (restricted page in an accessible space must NOT leak to the agent). - validateResolvedAddresses: SSRF connect-time guard (block if ANY resolved address is private). - resolveAudioFormat: mime whitelist (dead `?? 'webm'` fallback dropped, set unchanged). + mcp-servers toView header-leak guard, MCP tool namespacing. collaboration (data-loss area): - extract computeHistoryJob (pins the "agent delay MUST stay 0" invariant) and resolveSource. Integration: onAuthenticate read-only matrix (collab auth bypass), HistoryProcessor (contributor restore on save failure), onStoreDocument Approach-A boundary snapshot (human revision pinned before agent overwrite). Reviewed (APPROVE WITH SUGGESTIONS): extractions behaviour-preserving, security tests mutation-resistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
import { ShareService } from './share.service';
|
|
|
|
// Exercises the REAL ShareService comment-mark stripping for shared content via
|
|
// the smallest reachable seam: updatePublicAttachments -> prepareContentForShare
|
|
// -> removeMarkTypeFromDoc(doc, 'comment'). This is a documented threat-model
|
|
// item: `comment` marks are internal-team metadata (existence, location, count,
|
|
// resolved state, and the comment ids themselves) and MUST NOT leak to anonymous
|
|
// public-share viewers.
|
|
//
|
|
// prepareContentForShare is private and the page-load path (getSharedPage) needs
|
|
// a full DB-backed resolveReadableSharePage; updatePublicAttachments is the
|
|
// smallest public seam that runs the exact same sanitization on a doc we control.
|
|
// Only the workspace toggle (workspaceRepo.findById) and token service are
|
|
// touched, both mocked — no DB setup required.
|
|
|
|
const WS = 'ws-1';
|
|
const PAGE = 'page-1';
|
|
|
|
function buildService() {
|
|
const shareRepo = { findById: jest.fn() };
|
|
const pageRepo = { findById: jest.fn() };
|
|
const pagePermissionRepo = {
|
|
hasRestrictedAncestor: jest.fn(async () => false),
|
|
};
|
|
const tokenService = {
|
|
generateAttachmentToken: jest.fn(async () => 'tok'),
|
|
};
|
|
// htmlEmbed toggle ON so the embed strip is a no-op and we isolate the
|
|
// comment-mark strip behaviour.
|
|
const workspaceRepo = {
|
|
findById: jest.fn(async () => ({ id: WS, settings: { htmlEmbed: true } })),
|
|
};
|
|
|
|
return new ShareService(
|
|
shareRepo as any,
|
|
pageRepo as any,
|
|
pagePermissionRepo as any,
|
|
{} as any, // db (unused on this path)
|
|
tokenService as any,
|
|
{} as any, // transclusionService (unused)
|
|
workspaceRepo as any,
|
|
);
|
|
}
|
|
|
|
// A paragraph whose text carries a `comment` mark with a comment id.
|
|
function commentedText(text: string, commentId: string) {
|
|
return {
|
|
type: 'text',
|
|
text,
|
|
marks: [{ type: 'comment', attrs: { commentId, resolved: false } }],
|
|
};
|
|
}
|
|
|
|
async function sanitize(content: any) {
|
|
const service = buildService();
|
|
return service.updatePublicAttachments({
|
|
id: PAGE,
|
|
workspaceId: WS,
|
|
content,
|
|
} as any);
|
|
}
|
|
|
|
function countCommentMarks(doc: any): number {
|
|
let count = 0;
|
|
const walk = (node: any) => {
|
|
if (!node || typeof node !== 'object') return;
|
|
if (Array.isArray(node.marks)) {
|
|
for (const mark of node.marks) {
|
|
if (mark?.type === 'comment') count++;
|
|
}
|
|
}
|
|
if (Array.isArray(node.content)) node.content.forEach(walk);
|
|
};
|
|
walk(doc);
|
|
return count;
|
|
}
|
|
|
|
describe('ShareService comment-mark stripping for public shares (real code)', () => {
|
|
it('strips a top-level comment mark and preserves the visible text', async () => {
|
|
const content = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [commentedText('secret-reviewed body', 'cmt-top-1')],
|
|
},
|
|
],
|
|
};
|
|
|
|
const out = await sanitize(content);
|
|
|
|
expect(countCommentMarks(out)).toBe(0);
|
|
// The text itself survives; only the internal mark is removed.
|
|
expect(JSON.stringify(out)).toContain('secret-reviewed body');
|
|
// The comment id must not appear anywhere in the serialized output.
|
|
expect(JSON.stringify(out)).not.toContain('cmt-top-1');
|
|
});
|
|
|
|
it('strips comment marks nested inside columns and callouts', async () => {
|
|
const content = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'columns',
|
|
content: [
|
|
{
|
|
type: 'column',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [commentedText('col body', 'cmt-col-1')],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'column',
|
|
content: [
|
|
{
|
|
type: 'callout',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [commentedText('callout body', 'cmt-callout-1')],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const out = await sanitize(content);
|
|
|
|
expect(countCommentMarks(out)).toBe(0);
|
|
const serialized = JSON.stringify(out);
|
|
// Visible content of both nested branches survives.
|
|
expect(serialized).toContain('col body');
|
|
expect(serialized).toContain('callout body');
|
|
// No nested comment id leaks.
|
|
expect(serialized).not.toContain('cmt-col-1');
|
|
expect(serialized).not.toContain('cmt-callout-1');
|
|
});
|
|
|
|
it('strips every comment mark when multiple coexist (count goes to zero)', async () => {
|
|
const content = {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
commentedText('a', 'cmt-a'),
|
|
{ type: 'text', text: ' plain ' },
|
|
commentedText('b', 'cmt-b'),
|
|
],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
content: [commentedText('c', 'cmt-c')],
|
|
},
|
|
],
|
|
};
|
|
|
|
// Sanity: the input genuinely has 3 comment marks before sanitization.
|
|
expect(countCommentMarks(content)).toBe(3);
|
|
|
|
const out = await sanitize(content);
|
|
|
|
expect(countCommentMarks(out)).toBe(0);
|
|
const serialized = JSON.stringify(out);
|
|
for (const id of ['cmt-a', 'cmt-b', 'cmt-c']) {
|
|
expect(serialized).not.toContain(id);
|
|
}
|
|
});
|
|
});
|