Public sharing (#218): - Bind public-share content to the requested shareId. getSharedPage now enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must be reachable THROUGH that exact share (its own share, or an includeSubPages ancestor that contains it). A forged/mismatched shareId 404s instead of rendering off the slug alone and no longer leaks the real canonical key via redirect. A request with no shareId keeps the legacy slug-capability path. - Trim /shares/page-info: drop internal metadata (creatorId, spaceId, workspaceId, contributorIds, lastUpdated*, parent/position, lock/template flags, timestamps) from the anonymous payload. - Default share-to-web includeSubPages to false (opt-in), so enabling a share no longer silently exposes the whole sub-tree (#216). Editor (#218): - Harden the new-page pre-sync window: the body editor is kept read-only until the collab provider is Connected and synced, so early keystrokes can't land only in local ProseMirror and then be clobbered by the server's empty doc. - Surface a "Connecting… (read-only)" affordance during the static phase so input isn't silently swallowed. Other: - Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs) instead of waiting for the lazily-built sidebar tree, so deep pages don't render a blank breadcrumb for seconds. - Pasting GitHub `> [!type]` callouts now converts to a callout node instead of a literal blockquote (new marked extension wired into markdownToHtml). Tests: editor-sync-state gate (client), getSharedPage share-binding (server), github-callout markdown conversion (editor-ext). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
4.9 KiB
TypeScript
162 lines
4.9 KiB
TypeScript
import { NotFoundException } from '@nestjs/common';
|
|
import { ShareService } from './share.service';
|
|
|
|
/**
|
|
* Regression for issue #218: public-share content must be bound to the requested
|
|
* shareId. `getSharedPage` resolves the page off its slug, but when the caller
|
|
* supplies a shareId it must be reachable THROUGH that exact share — a forged or
|
|
* mismatched shareId 404s instead of rendering the page off its slug alone. A
|
|
* request with no shareId keeps the legacy slug-capability behavior.
|
|
*/
|
|
const WS = 'ws-1';
|
|
const PAGE_ID = 'page-uuid-1';
|
|
const OWN_SHARE_ID = 'share-own';
|
|
const OWN_SHARE_KEY = 'ownkey';
|
|
|
|
function buildService(over: {
|
|
resolvedShare?: any;
|
|
ancestorShare?: any; // returned by shareRepo.findById(requestedShareId)
|
|
ancestorFound?: boolean; // getShareAncestorPage result
|
|
} = {}) {
|
|
const resolvedShare = over.resolvedShare ?? {
|
|
id: OWN_SHARE_ID,
|
|
key: OWN_SHARE_KEY,
|
|
includeSubPages: false,
|
|
spaceId: 'space-1',
|
|
workspaceId: WS,
|
|
};
|
|
const page = { id: PAGE_ID, deletedAt: null, content: { type: 'doc' } };
|
|
|
|
const shareRepo = {
|
|
findById: jest.fn(async () => over.ancestorShare ?? null),
|
|
};
|
|
|
|
const service = new ShareService(
|
|
shareRepo as any,
|
|
{} as any, // pageRepo (resolveReadableSharePage is spied)
|
|
{} as any, // pagePermissionRepo
|
|
{} as any, // db
|
|
{} as any, // tokenService
|
|
{} as any, // transclusionService
|
|
{} as any, // workspaceRepo
|
|
);
|
|
|
|
jest
|
|
.spyOn(service, 'resolveReadableSharePage')
|
|
.mockResolvedValue({ share: resolvedShare, page } as any);
|
|
jest
|
|
.spyOn(service, 'updatePublicAttachments')
|
|
.mockResolvedValue(page.content as any);
|
|
jest
|
|
.spyOn(service, 'getShareAncestorPage')
|
|
.mockResolvedValue(over.ancestorFound ? { id: 'anc' } : null);
|
|
|
|
return { service, shareRepo, page, resolvedShare };
|
|
}
|
|
|
|
describe('ShareService.getSharedPage — share binding (#218)', () => {
|
|
it('returns the page when no shareId is supplied (legacy slug path)', async () => {
|
|
const { service } = buildService();
|
|
const out = await service.getSharedPage({ pageId: PAGE_ID } as any, WS);
|
|
expect(out.page.id).toBe(PAGE_ID);
|
|
});
|
|
|
|
it('returns the page when the shareId matches the resolved share key', async () => {
|
|
const { service } = buildService();
|
|
const out = await service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: OWN_SHARE_KEY } as any,
|
|
WS,
|
|
);
|
|
expect(out.page.id).toBe(PAGE_ID);
|
|
});
|
|
|
|
it('returns the page when the shareId matches the resolved share id (case-insensitive key)', async () => {
|
|
const { service } = buildService();
|
|
const out = await service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: OWN_SHARE_KEY.toUpperCase() } as any,
|
|
WS,
|
|
);
|
|
expect(out.page.id).toBe(PAGE_ID);
|
|
});
|
|
|
|
it('404s for a forged shareId that resolves to nothing', async () => {
|
|
const { service } = buildService({ ancestorShare: null });
|
|
await expect(
|
|
service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: 'doesnotexist99' } as any,
|
|
WS,
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('allows an includeSubPages ANCESTOR share that contains the page', async () => {
|
|
const { service } = buildService({
|
|
ancestorShare: {
|
|
id: 'ancestor-share',
|
|
pageId: 'ancestor-page',
|
|
includeSubPages: true,
|
|
workspaceId: WS,
|
|
},
|
|
ancestorFound: true,
|
|
});
|
|
const out = await service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: 'ancestorkey' } as any,
|
|
WS,
|
|
);
|
|
expect(out.page.id).toBe(PAGE_ID);
|
|
});
|
|
|
|
it('404s for a different share WITHOUT includeSubPages', async () => {
|
|
const { service } = buildService({
|
|
ancestorShare: {
|
|
id: 'other-share',
|
|
pageId: 'other-page',
|
|
includeSubPages: false,
|
|
workspaceId: WS,
|
|
},
|
|
});
|
|
await expect(
|
|
service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: 'otherkey' } as any,
|
|
WS,
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('404s for an includeSubPages share that does NOT contain the page', async () => {
|
|
const { service } = buildService({
|
|
ancestorShare: {
|
|
id: 'unrelated-share',
|
|
pageId: 'unrelated-page',
|
|
includeSubPages: true,
|
|
workspaceId: WS,
|
|
},
|
|
ancestorFound: false,
|
|
});
|
|
await expect(
|
|
service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: 'unrelatedkey' } as any,
|
|
WS,
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('404s for a share in a different workspace', async () => {
|
|
const { service } = buildService({
|
|
ancestorShare: {
|
|
id: 'foreign-share',
|
|
pageId: 'foreign-page',
|
|
includeSubPages: true,
|
|
workspaceId: 'other-ws',
|
|
},
|
|
ancestorFound: true,
|
|
});
|
|
await expect(
|
|
service.getSharedPage(
|
|
{ pageId: PAGE_ID, shareId: 'foreignkey' } as any,
|
|
WS,
|
|
),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|