Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192): - export/utils: correct the misleading getInternalLinkPageName comment — a bare `v1.2` loses its last dot-segment (`v1`); dots survive only in multi-segment names like `v1.2.md` -> `v1.2`. - share: extract toPublicSharePayload(page, share): PublicSharePayload, an explicit allowlist type+mapper replacing the inline literal in the /shares/page-info anonymous path (#218). Add share.controller.spec.ts that stubs getSharedPage returning internal fields and asserts the response key set EXACTLY equals the whitelist (page + share), so any `...shareData` regression or new leaking field fails. Also key-tests the extracted mapper. - breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId) (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode, dropping the as-any casts; else null) and unit-test all three branches. - share-modal: RTL test asserting enabling a share calls mutateAsync with includeSubPages: false (#216 security default). - share.service: one-line note at getSharedPage on the deferred consolidation of the ancestor-aware match into resolveReadableSharePage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
5.5 KiB
TypeScript
191 lines
5.5 KiB
TypeScript
import { ShareController } from './share.controller';
|
|
import {
|
|
PublicSharePayload,
|
|
toPublicSharePayload,
|
|
} from './share-public-payload';
|
|
|
|
// The `/shares/page-info` route is the ONLY anonymous path that serializes the
|
|
// full {page, share} records. Trimming the response to an explicit allowlist is
|
|
// a security control (#218): a regression that returns `...shareData` (or adds a
|
|
// new field to the allowlist) must fail loudly. These tests lock the exact key
|
|
// set returned to anonymous viewers so internal metadata can never silently leak.
|
|
|
|
const PAGE_KEYS = ['id', 'slugId', 'title', 'icon', 'content'].sort();
|
|
const SHARE_KEYS = [
|
|
'id',
|
|
'key',
|
|
'includeSubPages',
|
|
'searchIndexing',
|
|
'level',
|
|
'sharedPage',
|
|
].sort();
|
|
|
|
// A page row carrying internal metadata that MUST NOT reach anonymous viewers.
|
|
function internalPage() {
|
|
return {
|
|
id: 'page-1',
|
|
slugId: 'slug-1',
|
|
title: 'Public Title',
|
|
icon: '📄',
|
|
content: { type: 'doc', content: [] },
|
|
// --- leaky internals ---
|
|
creatorId: 'user-1',
|
|
lastUpdatedById: 'user-2',
|
|
contributorIds: ['user-1', 'user-2'],
|
|
spaceId: 'space-1',
|
|
workspaceId: 'ws-1',
|
|
parentPageId: 'parent-1',
|
|
position: 'aa',
|
|
isLocked: true,
|
|
isTemplate: false,
|
|
textContent: 'secret text content',
|
|
ydoc: Buffer.from('binary'),
|
|
createdAt: new Date('2020-01-01'),
|
|
updatedAt: new Date('2020-01-02'),
|
|
deletedAt: null,
|
|
} as any;
|
|
}
|
|
|
|
// A resolved share carrying internal metadata.
|
|
function internalShare() {
|
|
return {
|
|
id: 'share-1',
|
|
key: 'share-key',
|
|
includeSubPages: false,
|
|
searchIndexing: true,
|
|
level: 0,
|
|
sharedPage: { id: 'page-1', slugId: 'slug-1', title: 'Public Title' },
|
|
// --- leaky internals ---
|
|
creatorId: 'user-1',
|
|
spaceId: 'space-1',
|
|
workspaceId: 'ws-1',
|
|
pageId: 'page-1',
|
|
createdAt: new Date('2020-01-01'),
|
|
updatedAt: new Date('2020-01-02'),
|
|
deletedAt: null,
|
|
} as any;
|
|
}
|
|
|
|
function buildController(over?: { aiAssistant?: boolean }) {
|
|
const shareService = {
|
|
// Deliberately returns the FULL internal records (as the real service does).
|
|
getSharedPage: jest.fn(async () => ({
|
|
page: internalPage(),
|
|
share: internalShare(),
|
|
})),
|
|
isSharingAllowed: jest.fn(async () => true),
|
|
};
|
|
const aiSettings = {
|
|
isPublicShareAssistantEnabled: jest.fn(
|
|
async () => over?.aiAssistant ?? false,
|
|
),
|
|
resolvePublicShareAssistantName: jest.fn(async () => 'Assistant'),
|
|
};
|
|
const licenseCheckService = {
|
|
resolveFeatures: jest.fn(() => ({ tier: 'free' })),
|
|
};
|
|
|
|
const controller = new ShareController(
|
|
shareService as any,
|
|
{} as any, // shareRepo
|
|
{} as any, // pageRepo
|
|
{} as any, // pagePermissionRepo
|
|
{} as any, // pageAccessService
|
|
licenseCheckService as any,
|
|
aiSettings as any,
|
|
{} as any, // auditService
|
|
);
|
|
|
|
return { controller, shareService, aiSettings, licenseCheckService };
|
|
}
|
|
|
|
const workspace = {
|
|
id: 'ws-1',
|
|
licenseKey: null,
|
|
plan: 'free',
|
|
} as any;
|
|
|
|
describe('ShareController.getSharedPageInfo — public payload whitelist (#218)', () => {
|
|
it('returns EXACTLY the page allowlist keys (no leaked internals)', async () => {
|
|
const { controller } = buildController();
|
|
|
|
const res = await controller.getSharedPageInfo(
|
|
{ pageId: 'page-1' } as any,
|
|
workspace,
|
|
);
|
|
|
|
expect(Object.keys(res.page).sort()).toEqual(PAGE_KEYS);
|
|
for (const leaked of [
|
|
'creatorId',
|
|
'lastUpdatedById',
|
|
'contributorIds',
|
|
'spaceId',
|
|
'workspaceId',
|
|
'parentPageId',
|
|
'position',
|
|
'textContent',
|
|
'ydoc',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'deletedAt',
|
|
]) {
|
|
expect((res.page as any)[leaked]).toBeUndefined();
|
|
}
|
|
// The serialized payload must not carry the secret text content either.
|
|
expect(JSON.stringify(res.page)).not.toContain('secret text content');
|
|
});
|
|
|
|
it('returns EXACTLY the share allowlist keys (no leaked internals)', async () => {
|
|
const { controller } = buildController();
|
|
|
|
const res = await controller.getSharedPageInfo(
|
|
{ pageId: 'page-1' } as any,
|
|
workspace,
|
|
);
|
|
|
|
expect(Object.keys(res.share).sort()).toEqual(SHARE_KEYS);
|
|
for (const leaked of [
|
|
'creatorId',
|
|
'spaceId',
|
|
'workspaceId',
|
|
'pageId',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'deletedAt',
|
|
]) {
|
|
expect((res.share as any)[leaked]).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('surfaces the public AI-assistant flags and license features alongside the trimmed payload', async () => {
|
|
const { controller } = buildController({ aiAssistant: true });
|
|
|
|
const res = await controller.getSharedPageInfo(
|
|
{ pageId: 'page-1' } as any,
|
|
workspace,
|
|
);
|
|
|
|
expect(res.aiAssistant).toBe(true);
|
|
expect(res.aiAssistantName).toBe('Assistant');
|
|
expect(res.features).toEqual({ tier: 'free' });
|
|
// Top-level keys are limited to the trimmed payload + the public extras.
|
|
expect(Object.keys(res).sort()).toEqual(
|
|
['page', 'share', 'aiAssistant', 'aiAssistantName', 'features'].sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('toPublicSharePayload — key set is the contract', () => {
|
|
it('copies only the allowlisted page/share keys', () => {
|
|
const payload: PublicSharePayload = toPublicSharePayload(
|
|
internalPage(),
|
|
internalShare(),
|
|
);
|
|
|
|
expect(Object.keys(payload.page).sort()).toEqual(PAGE_KEYS);
|
|
expect(Object.keys(payload.share).sort()).toEqual(SHARE_KEYS);
|
|
expect(payload.page.id).toBe('page-1');
|
|
expect(payload.share.key).toBe('share-key');
|
|
});
|
|
});
|