Files
gitmost/apps/server/src/core/share/share-get-shared-page-binding.spec.ts
claude code agent 227 22852be2e2 fix(qa): resolve UI bugs from #216 and #218
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>
2026-06-27 05:54:06 +03:00

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