test(server): batch 5 authorization, transclusion, search & comment coverage
Test-only. Fills the authorization / data-integrity gaps from the strategy report. Full server suite: 100 suites / 1031 passed + 1 todo, green. Authorization (privilege-escalation catches): - workspace/space ability factories: exact can/cannot per (action,subject) — admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc. - findHighestUserSpaceRole, isAdminActingOnOwner. - WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target. - SpaceMemberService.validateLastAdmin: never orphan a space without an admin. - GroupService: default-group immutability, name uniqueness. Access / data integrity: - PageAccessService: restriction-vs-space-ability branches for view/edit/comment. - TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts NO attachment write or ref-row delete on rejection; lookupWithAccessSet positional status mapping; listReferences drops private/cross-ws/deleted refs; syncPageTransclusions/References diff (no-op on unchanged content). - SearchService.searchPage: query-mode scoping; leakage modes return empty before executing the query. - CommentService: reply-to-reply guard, agent provenance, self-mention filter, no double-notify. Pure helpers: - prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID validation, removeMarkTypeFromDoc), collaboration.util (getPageId, isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement). Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { PageAccessService } from './page-access.service';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
|
||||
/**
|
||||
* Unit tests for PageAccessService — the privilege-escalation surface of the
|
||||
* page-access layer. The service is constructed directly with three jest-mocked
|
||||
* positional deps in the exact constructor order:
|
||||
*
|
||||
* new PageAccessService(pagePermissionRepo, spaceAbility, spaceRepo)
|
||||
*
|
||||
* The CASL ability returned by `spaceAbility.createForUser` is mocked as a plain
|
||||
* object exposing `can`/`cannot`. We drive `can`/`cannot` per (action, subject)
|
||||
* so the restriction-vs-space-level branch logic can be exercised precisely.
|
||||
*
|
||||
* The most dangerous bug class here is branch inversion: if `validateCanEdit`
|
||||
* reads the SPACE ability when the page is restricted (or vice versa), a viewer
|
||||
* could edit a restricted page, or a page-level writer could be blocked. The
|
||||
* tests below pin the EXACT source of the edit decision for each branch.
|
||||
*/
|
||||
|
||||
type AbilityDecision = (
|
||||
action: SpaceCaslAction,
|
||||
subject: SpaceCaslSubject,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Build a CASL-like ability stub. `decide` returns true when the user CAN do
|
||||
* (action, subject). `cannot` is the strict negation of `can`, matching CASL.
|
||||
*/
|
||||
function makeAbility(decide: AbilityDecision) {
|
||||
return {
|
||||
can: jest.fn((action: SpaceCaslAction, subject: SpaceCaslSubject) =>
|
||||
decide(action, subject),
|
||||
),
|
||||
cannot: jest.fn(
|
||||
(action: SpaceCaslAction, subject: SpaceCaslSubject) =>
|
||||
!decide(action, subject),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common "space member" ability: can Read pages, edit governed by `canEdit`.
|
||||
*/
|
||||
function memberAbility(canEdit: boolean) {
|
||||
return makeAbility((action, subject) => {
|
||||
if (subject !== SpaceCaslSubject.Page) return false;
|
||||
if (action === SpaceCaslAction.Read) return true;
|
||||
if (action === SpaceCaslAction.Edit) return canEdit;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Ability of a user who is NOT a space member: cannot even Read. */
|
||||
function nonMemberAbility() {
|
||||
return makeAbility(() => false);
|
||||
}
|
||||
|
||||
function buildService(opts: {
|
||||
ability: ReturnType<typeof makeAbility>;
|
||||
canUserEditPage?: () => Promise<{
|
||||
hasAnyRestriction: boolean;
|
||||
canAccess: boolean;
|
||||
canEdit: boolean;
|
||||
}>;
|
||||
canUserAccessPage?: () => Promise<boolean>;
|
||||
space?: unknown;
|
||||
}) {
|
||||
const pagePermissionRepo = {
|
||||
canUserEditPage: jest.fn(
|
||||
opts.canUserEditPage ??
|
||||
(async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
})),
|
||||
),
|
||||
canUserAccessPage: jest.fn(
|
||||
opts.canUserAccessPage ?? (async () => true),
|
||||
),
|
||||
};
|
||||
const spaceAbility = {
|
||||
createForUser: jest.fn().mockResolvedValue(opts.ability),
|
||||
};
|
||||
const spaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.space ?? null),
|
||||
};
|
||||
|
||||
const service = new PageAccessService(
|
||||
pagePermissionRepo as any,
|
||||
spaceAbility as any,
|
||||
spaceRepo as any,
|
||||
);
|
||||
return { service, pagePermissionRepo, spaceAbility, spaceRepo };
|
||||
}
|
||||
|
||||
const page = { id: 'page-1', spaceId: 'space-1' } as any;
|
||||
const user = { id: 'user-1' } as any;
|
||||
|
||||
describe('PageAccessService.validateCanEdit', () => {
|
||||
it('throws Forbidden when the user is not a space member (cannot Read)', async () => {
|
||||
const { service, pagePermissionRepo } = buildService({
|
||||
ability: nonMemberAbility(),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
// Must short-circuit before ever consulting page-level permissions.
|
||||
expect(pagePermissionRepo.canUserEditPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws Forbidden when page is restricted and page-level canEdit is false', async () => {
|
||||
// Restriction present -> the page-level writer flag governs. Even though the
|
||||
// space ability grants Edit, a restricted page without a writer grant blocks.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns {hasRestriction:true} when page is restricted and page-level canEdit is true', async () => {
|
||||
// Restricted + page-level writer grant. The SPACE ability denies Edit, but
|
||||
// the page-level grant must win — a branch inversion here would block a
|
||||
// legitimate page writer.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).resolves.toEqual({
|
||||
hasRestriction: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws Forbidden when page is unrestricted but the space ability denies Edit', async () => {
|
||||
// No restriction -> the space-level Edit decides. Space denies -> Forbidden,
|
||||
// even though page-level canEdit happens to be true (must be ignored here).
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns {hasRestriction:false} when page is unrestricted and the space allows Edit', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false, // ignored: unrestricted -> space ability governs
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(service.validateCanEdit(page, user)).resolves.toEqual({
|
||||
hasRestriction: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageAccessService.validateCanViewWithPermissions', () => {
|
||||
it('throws Forbidden when restricted and canAccess is false', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('restricted+accessible: canEdit is taken from canUserEditPage (NOT the space ability)', async () => {
|
||||
// Space ability would say "can edit" — but because the page is restricted,
|
||||
// the repo's page-level canEdit (false here) must be returned instead.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: false, hasRestriction: true });
|
||||
});
|
||||
|
||||
it('restricted+accessible: surfaces page-level canEdit true', async () => {
|
||||
// Space ability denies Edit, but page-level writer grant must surface.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: true, hasRestriction: true });
|
||||
});
|
||||
|
||||
it('unrestricted: canEdit comes from the SPACE ability, not the repo', async () => {
|
||||
// hasAnyRestriction false -> the SPACE Edit ability decides. The repo's
|
||||
// canEdit (false) must be ignored; the space grant (true) must win.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: true, hasRestriction: false });
|
||||
});
|
||||
|
||||
it('unrestricted: space-denied Edit yields canEdit false even if repo says true', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true, // ignored
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).resolves.toEqual({ canEdit: false, hasRestriction: false });
|
||||
});
|
||||
|
||||
it('throws Forbidden when the user is not a space member', async () => {
|
||||
const { service, pagePermissionRepo } = buildService({
|
||||
ability: nonMemberAbility(),
|
||||
});
|
||||
await expect(
|
||||
service.validateCanViewWithPermissions(page, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pagePermissionRepo.canUserEditPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageAccessService.validateCanComment', () => {
|
||||
it('returns immediately for an editor (validateCanEdit succeeds)', async () => {
|
||||
// Editor path: validateCanEdit resolves, so view/space-settings are never
|
||||
// consulted. allowViewerComments is irrelevant for an editor.
|
||||
const { service, spaceRepo, pagePermissionRepo } = buildService({
|
||||
ability: memberAbility(true),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).resolves.toBeUndefined();
|
||||
// No need to fall through to the space-settings viewer-comment gate.
|
||||
expect(spaceRepo.findById).not.toHaveBeenCalled();
|
||||
expect(pagePermissionRepo.canUserAccessPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes for a non-editor viewer when allowViewerComments is true', async () => {
|
||||
// Not an editor (space denies Edit, no restriction) but can view, and the
|
||||
// space setting allows viewer comments -> resolves.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => true,
|
||||
space: { settings: { comments: { allowViewerComments: true } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws Forbidden for a non-editor viewer when allowViewerComments is false', async () => {
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => true,
|
||||
space: { settings: { comments: { allowViewerComments: false } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws Forbidden for a non-editor viewer when the setting is absent', async () => {
|
||||
// No comments settings at all (and a null space) -> the viewer-comment gate
|
||||
// is closed by default.
|
||||
const { service } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => true,
|
||||
space: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws Forbidden when the user cannot view (non-editor AND no view access)', async () => {
|
||||
// Not an editor, and validateCanView fails (canUserAccessPage false) -> the
|
||||
// viewer-comment branch is never reached; Forbidden from validateCanView.
|
||||
const { service, spaceRepo } = buildService({
|
||||
ability: memberAbility(false),
|
||||
canUserEditPage: async () => ({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
}),
|
||||
canUserAccessPage: async () => false,
|
||||
space: { settings: { comments: { allowViewerComments: true } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.validateCanComment(page, user, 'ws-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// view check fails before we ever look at space settings.
|
||||
expect(spaceRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Tests for TransclusionService.listReferences — returns the source page info
|
||||
* plus the list of pages that reference a given sync block. This is a read path
|
||||
* that leaks title/icon/slug, so it MUST drop any referencing page the viewer
|
||||
* cannot see, any soft-deleted page, and any cross-workspace page — even if such
|
||||
* an id slipped through the referencePageIds filter.
|
||||
*
|
||||
* Collaborating methods/repos:
|
||||
* - pageTransclusionReferencesRepo.findReferencePageIdsByTransclusion(
|
||||
* sourcePageId, transclusionId, workspaceId) -> string[]
|
||||
* - filterViewerAccessiblePageIds(...) -> accessible ids (spied/stubbed)
|
||||
* - pageRepo.findById(id, { includeSpace: true }) -> page row (per id)
|
||||
*
|
||||
* Output ordering: `references` preserves the order of `referencePageIds`.
|
||||
* Catch: leaking title/icon of a private/cross-workspace referencing page.
|
||||
*/
|
||||
|
||||
const WS = 'w1';
|
||||
|
||||
function pageRow(over: Partial<any>) {
|
||||
return {
|
||||
id: 'x',
|
||||
slugId: 'slug-x',
|
||||
title: 'Title X',
|
||||
icon: '📄',
|
||||
spaceId: 'space-x',
|
||||
deletedAt: null,
|
||||
workspaceId: WS,
|
||||
space: { slug: 'space-slug-x' },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function buildService(opts: {
|
||||
referencePageIds: string[];
|
||||
accessibleIds: string[];
|
||||
pagesById: Record<string, any | null>;
|
||||
}) {
|
||||
const findReferencePageIdsByTransclusion = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.referencePageIds);
|
||||
const pageTransclusionReferencesRepo = {
|
||||
findReferencePageIdsByTransclusion,
|
||||
};
|
||||
const findById = jest.fn(async (id: string) => opts.pagesById[id] ?? null);
|
||||
const pageRepo = { findById };
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, 'filterViewerAccessiblePageIds')
|
||||
.mockResolvedValue(opts.accessibleIds);
|
||||
|
||||
return { service, findById, findReferencePageIdsByTransclusion };
|
||||
}
|
||||
|
||||
describe('TransclusionService.listReferences', () => {
|
||||
it('returns only accessible references; an inaccessible reference is excluded', async () => {
|
||||
// refs: pub (accessible) and priv (NOT accessible). source accessible too.
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['pub', 'priv'],
|
||||
accessibleIds: ['src', 'pub'], // priv missing -> filtered out
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src', slugId: 'src-slug', title: 'Src' }),
|
||||
pub: pageRow({ id: 'pub', slugId: 'pub-slug', title: 'Public ref' }),
|
||||
priv: pageRow({ id: 'priv', title: 'Private ref' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.source?.id).toBe('src');
|
||||
expect(result.references.map((r) => r.id)).toEqual(['pub']);
|
||||
// The private page's title must never appear.
|
||||
const json = JSON.stringify(result.references);
|
||||
expect(json).not.toContain('Private ref');
|
||||
});
|
||||
|
||||
it('drops a soft-deleted reference even though it passed the id filter', async () => {
|
||||
// "stale" is in referencePageIds AND in accessibleIds, but its page row is
|
||||
// soft-deleted -> must be dropped by the post-load workspace/deleted guard.
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['live', 'stale'],
|
||||
accessibleIds: ['src', 'live', 'stale'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
live: pageRow({ id: 'live', title: 'Live ref' }),
|
||||
stale: pageRow({ id: 'stale', title: 'Stale ref', deletedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.references.map((r) => r.id)).toEqual(['live']);
|
||||
expect(JSON.stringify(result.references)).not.toContain('Stale ref');
|
||||
});
|
||||
|
||||
it('drops a cross-workspace reference even though it passed the id filter', async () => {
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['mine', 'foreign'],
|
||||
accessibleIds: ['src', 'mine', 'foreign'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
mine: pageRow({ id: 'mine', title: 'Mine' }),
|
||||
foreign: pageRow({
|
||||
id: 'foreign',
|
||||
title: 'Foreign',
|
||||
workspaceId: 'other-ws',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.references.map((r) => r.id)).toEqual(['mine']);
|
||||
expect(JSON.stringify(result.references)).not.toContain('Foreign');
|
||||
});
|
||||
|
||||
it('returns source:null when the source is inaccessible but still lists accessible refs', async () => {
|
||||
// Viewer can see the referencing page but NOT the source page itself.
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['pub'],
|
||||
accessibleIds: ['pub'], // src not accessible
|
||||
pagesById: {
|
||||
pub: pageRow({ id: 'pub', title: 'Public ref' }),
|
||||
src: pageRow({ id: 'src', title: 'Hidden source' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.source).toBeNull();
|
||||
expect(result.references.map((r) => r.id)).toEqual(['pub']);
|
||||
});
|
||||
|
||||
it('short-circuits to {source:null, references:[]} when nothing is accessible', async () => {
|
||||
const { service, findById } = buildService({
|
||||
referencePageIds: ['a', 'b'],
|
||||
accessibleIds: [], // nothing accessible
|
||||
pagesById: {
|
||||
a: pageRow({ id: 'a' }),
|
||||
b: pageRow({ id: 'b' }),
|
||||
src: pageRow({ id: 'src' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ source: null, references: [] });
|
||||
// No page bodies loaded when the accessible set is empty.
|
||||
expect(findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the order of referencePageIds in the output', async () => {
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['c', 'a', 'b'],
|
||||
accessibleIds: ['src', 'a', 'b', 'c'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
a: pageRow({ id: 'a', title: 'A' }),
|
||||
b: pageRow({ id: 'b', title: 'B' }),
|
||||
c: pageRow({ id: 'c', title: 'C' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
// Output order must follow referencePageIds (c, a, b), NOT sorted/byId order.
|
||||
expect(result.references.map((r) => r.id)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('maps page fields and space slug into the reference info shape', async () => {
|
||||
const { service } = buildService({
|
||||
referencePageIds: ['pub'],
|
||||
accessibleIds: ['src', 'pub'],
|
||||
pagesById: {
|
||||
src: pageRow({ id: 'src' }),
|
||||
pub: pageRow({
|
||||
id: 'pub',
|
||||
slugId: 'pub-slug',
|
||||
title: 'Public',
|
||||
icon: '🔗',
|
||||
spaceId: 'space-pub',
|
||||
space: { slug: 'pub-space' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.listReferences({
|
||||
sourcePageId: 'src',
|
||||
transclusionId: 't1',
|
||||
viewerUserId: 'u1',
|
||||
workspaceId: WS,
|
||||
});
|
||||
|
||||
expect(result.references[0]).toEqual({
|
||||
id: 'pub',
|
||||
slugId: 'pub-slug',
|
||||
title: 'Public',
|
||||
icon: '🔗',
|
||||
spaceId: 'space-pub',
|
||||
spaceSlug: 'pub-space',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Tests for TransclusionService.lookupWithAccessSet — the positional resolver
|
||||
* that maps an ordered list of `(sourcePageId, transclusionId)` references onto
|
||||
* an output array of the SAME length and order. The caller supplies the set of
|
||||
* accessible source page ids; this method only resolves content for those, and
|
||||
* must never let one page's content surface under another page's slot.
|
||||
*
|
||||
* The two repos it touches:
|
||||
* - pageTransclusionsRepo.findManyByPageAndTransclusion(keys, workspaceId)
|
||||
* -> rows of { pageId, transclusionId, content }
|
||||
* - pageRepo.findManyByIds(ids, { workspaceId })
|
||||
* -> pages of { id, updatedAt } (used only for sourceUpdatedAt / not_found)
|
||||
*
|
||||
* Result statuses (transclusion.service.ts ~533):
|
||||
* - source not in accessibleSet -> 'no_access'
|
||||
* - accessible but page meta missing -> 'not_found'
|
||||
* - accessible + page present, row missing -> 'not_found'
|
||||
* - accessible + page present + row present-> { content, sourceUpdatedAt }
|
||||
*
|
||||
* Catch: positional misalignment leaking one page's content under another's
|
||||
* slot. We assert each output index carries the right sourcePageId/content.
|
||||
*/
|
||||
|
||||
const now = (n: number) => new Date(`2026-06-2${n}T00:00:00.000Z`);
|
||||
|
||||
function buildService(opts: {
|
||||
rows: Array<{ pageId: string; transclusionId: string; content: unknown }>;
|
||||
pages: Array<{ id: string; updatedAt: Date }>;
|
||||
}) {
|
||||
const findManyByPageAndTransclusion = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.rows);
|
||||
const findManyByIds = jest.fn().mockResolvedValue(opts.pages);
|
||||
|
||||
const pageTransclusionsRepo = { findManyByPageAndTransclusion };
|
||||
const pageRepo = { findManyByIds };
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
pageTransclusionsRepo as any,
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
return { service, findManyByPageAndTransclusion, findManyByIds };
|
||||
}
|
||||
|
||||
describe('TransclusionService.lookupWithAccessSet', () => {
|
||||
it('returns {items:[]} for empty references and queries nothing', async () => {
|
||||
const { service, findManyByPageAndTransclusion, findManyByIds } =
|
||||
buildService({ rows: [], pages: [] });
|
||||
|
||||
const result = await service.lookupWithAccessSet([], new Set(['p1']), 'w1');
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(findManyByPageAndTransclusion).not.toHaveBeenCalled();
|
||||
expect(findManyByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a source not in the accessibleSet as no_access', async () => {
|
||||
const { service } = buildService({ rows: [], pages: [] });
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'private', transclusionId: 't1' }],
|
||||
new Set(), // nothing accessible
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'private', transclusionId: 't1', status: 'no_access' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks an accessible page with no meta (missing/deleted) as not_found', async () => {
|
||||
// Accessible, but pageRepo returns no page row -> no updatedAt -> not_found.
|
||||
const { service } = buildService({ rows: [], pages: [] });
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'gone', transclusionId: 't1' }],
|
||||
new Set(['gone']),
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'gone', transclusionId: 't1', status: 'not_found' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accessible page present but no transclusion row -> not_found', async () => {
|
||||
const { service } = buildService({
|
||||
rows: [], // no matching transclusion row
|
||||
pages: [{ id: 'p1', updatedAt: now(0) }],
|
||||
});
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'p1', transclusionId: 't1' }],
|
||||
new Set(['p1']),
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'p1', transclusionId: 't1', status: 'not_found' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accessible + row present -> content with sourceUpdatedAt', async () => {
|
||||
const content = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const { service } = buildService({
|
||||
rows: [{ pageId: 'p1', transclusionId: 't1', content }],
|
||||
pages: [{ id: 'p1', updatedAt: now(0) }],
|
||||
});
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[{ sourcePageId: 'p1', transclusionId: 't1' }],
|
||||
new Set(['p1']),
|
||||
'w1',
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't1',
|
||||
content,
|
||||
sourceUpdatedAt: now(0),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps positional alignment across a mixed batch (no cross-slot leakage)', async () => {
|
||||
// Order: [no_access, content(p2/t-a), not_found(no row), content(p3/t-b)]
|
||||
const cA = { type: 'doc', content: [{ type: 'text', text: 'A' }] };
|
||||
const cB = { type: 'doc', content: [{ type: 'text', text: 'B' }] };
|
||||
const { service } = buildService({
|
||||
rows: [
|
||||
{ pageId: 'p2', transclusionId: 't-a', content: cA },
|
||||
{ pageId: 'p3', transclusionId: 't-b', content: cB },
|
||||
],
|
||||
pages: [
|
||||
{ id: 'p2', updatedAt: now(1) },
|
||||
{ id: 'p3', updatedAt: now(2) },
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[
|
||||
{ sourcePageId: 'p1', transclusionId: 't-x' }, // not accessible
|
||||
{ sourcePageId: 'p2', transclusionId: 't-a' }, // content A
|
||||
{ sourcePageId: 'p2', transclusionId: 't-missing' }, // no row -> not_found
|
||||
{ sourcePageId: 'p3', transclusionId: 't-b' }, // content B
|
||||
],
|
||||
new Set(['p2', 'p3']),
|
||||
'w1',
|
||||
);
|
||||
|
||||
expect(items[0]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't-x',
|
||||
status: 'no_access',
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
sourcePageId: 'p2',
|
||||
transclusionId: 't-a',
|
||||
content: cA,
|
||||
sourceUpdatedAt: now(1),
|
||||
});
|
||||
expect(items[2]).toEqual({
|
||||
sourcePageId: 'p2',
|
||||
transclusionId: 't-missing',
|
||||
status: 'not_found',
|
||||
});
|
||||
expect(items[3]).toEqual({
|
||||
sourcePageId: 'p3',
|
||||
transclusionId: 't-b',
|
||||
content: cB,
|
||||
sourceUpdatedAt: now(2),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves duplicate (sourcePageId, transclusionId) references independently and keeps position', async () => {
|
||||
// The same ref appears twice; both slots must resolve to the same content,
|
||||
// and a DIFFERENT transclusionId on the same page must not bleed in.
|
||||
const cSame = { type: 'doc', content: [{ type: 'text', text: 'same' }] };
|
||||
const cOther = { type: 'doc', content: [{ type: 'text', text: 'other' }] };
|
||||
const { service } = buildService({
|
||||
rows: [
|
||||
{ pageId: 'p1', transclusionId: 't1', content: cSame },
|
||||
{ pageId: 'p1', transclusionId: 't2', content: cOther },
|
||||
],
|
||||
pages: [{ id: 'p1', updatedAt: now(3) }],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupWithAccessSet(
|
||||
[
|
||||
{ sourcePageId: 'p1', transclusionId: 't1' },
|
||||
{ sourcePageId: 'p1', transclusionId: 't2' },
|
||||
{ sourcePageId: 'p1', transclusionId: 't1' }, // duplicate of slot 0
|
||||
],
|
||||
new Set(['p1']),
|
||||
'w1',
|
||||
);
|
||||
|
||||
expect(items[0]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't1',
|
||||
content: cSame,
|
||||
sourceUpdatedAt: now(3),
|
||||
});
|
||||
expect(items[1]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't2',
|
||||
content: cOther,
|
||||
sourceUpdatedAt: now(3),
|
||||
});
|
||||
expect(items[2]).toEqual({
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 't1',
|
||||
content: cSame,
|
||||
sourceUpdatedAt: now(3),
|
||||
});
|
||||
});
|
||||
|
||||
it('only queries transclusions for accessible references', async () => {
|
||||
// The inaccessible page id must never appear in the repo key list — that
|
||||
// would itself be an existence-leak surface.
|
||||
const { service, findManyByPageAndTransclusion, findManyByIds } =
|
||||
buildService({
|
||||
rows: [{ pageId: 'ok', transclusionId: 't1', content: {} }],
|
||||
pages: [{ id: 'ok', updatedAt: now(0) }],
|
||||
});
|
||||
|
||||
await service.lookupWithAccessSet(
|
||||
[
|
||||
{ sourcePageId: 'secret', transclusionId: 'tz' },
|
||||
{ sourcePageId: 'ok', transclusionId: 't1' },
|
||||
],
|
||||
new Set(['ok']),
|
||||
'w1',
|
||||
);
|
||||
|
||||
const keys = findManyByPageAndTransclusion.mock.calls[0][0];
|
||||
expect(keys).toEqual([{ pageId: 'ok', transclusionId: 't1' }]);
|
||||
expect(findManyByPageAndTransclusion.mock.calls[0][1]).toBe('w1');
|
||||
expect(findManyByIds.mock.calls[0][0]).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Diff-logic tests for TransclusionService.syncPageTransclusions and
|
||||
* syncPageReferences. Both diff the desired state (parsed from PM JSON) against
|
||||
* the existing rows and issue only the minimal inserts/updates/deletes.
|
||||
*
|
||||
* The collector `collectTransclusionsFromPmJson` maps a `transclusionSource`
|
||||
* node to a snapshot of:
|
||||
* { transclusionId: <attrs.id>, content: { type: 'doc', content: <node.content ?? []> } }
|
||||
* So for the "unchanged -> no write" branch, the existing row's `content` must
|
||||
* deep-equal exactly that shape (isDeepStrictEqual). We mirror that here.
|
||||
*
|
||||
* Catch: spurious writes on unchanged content (the isDeepStrictEqual no-op
|
||||
* branch) and reference-sync drift (key must be `sourcePageId::transclusionId`,
|
||||
* so two refs differing only in transclusionId are distinct rows).
|
||||
*/
|
||||
|
||||
// Build a doc with one `transclusionSource` per (id, content-children) entry.
|
||||
function transclusionDoc(
|
||||
entries: Array<{ id: string; children?: unknown[] }>,
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: entries.map((e) => ({
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: e.id },
|
||||
content: e.children ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// The snapshot content shape the collector produces for the given children.
|
||||
function snapshotContent(children: unknown[] = []) {
|
||||
return { type: 'doc', content: children };
|
||||
}
|
||||
|
||||
function buildTransclusionService(existing: Array<any>) {
|
||||
const insert = jest.fn().mockResolvedValue(undefined);
|
||||
const update = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByPageAndTransclusionIds = jest.fn().mockResolvedValue(undefined);
|
||||
const findByPageId = jest.fn().mockResolvedValue(existing);
|
||||
|
||||
const pageTransclusionsRepo = {
|
||||
findByPageId,
|
||||
insert,
|
||||
update,
|
||||
deleteByPageAndTransclusionIds,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
pageTransclusionsRepo as any,
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
return { service, insert, update, deleteByPageAndTransclusionIds };
|
||||
}
|
||||
|
||||
describe('TransclusionService.syncPageTransclusions (diff logic)', () => {
|
||||
it('inserts a brand-new transclusion id', async () => {
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([{ id: 't-new', children: [{ type: 'paragraph' }] }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
|
||||
expect(insert).toHaveBeenCalledTimes(1);
|
||||
expect(insert.mock.calls[0][0]).toEqual({
|
||||
workspaceId: 'w1',
|
||||
pageId: 'page-1',
|
||||
transclusionId: 't-new',
|
||||
content: snapshotContent([{ type: 'paragraph' }]),
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates an existing id when its content changed (isDeepStrictEqual false)', async () => {
|
||||
const { service, insert, update } = buildTransclusionService([
|
||||
{ transclusionId: 't1', content: snapshotContent([{ type: 'old' }]) },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([{ id: 't1', children: [{ type: 'new' }] }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
const [pageId, transclusionId, data] = update.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(transclusionId).toBe('t1');
|
||||
expect(data).toEqual({ content: snapshotContent([{ type: 'new' }]) });
|
||||
});
|
||||
|
||||
it('does NOT write when content is identical (no-op branch)', async () => {
|
||||
// The existing row content deep-equals the collector's snapshot exactly.
|
||||
const children = [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }];
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([
|
||||
{ transclusionId: 't1', content: snapshotContent(children) },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([{ id: 't1', children }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an existing id absent from the desired set', async () => {
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([
|
||||
{ transclusionId: 'gone', content: snapshotContent([]) },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([]), // nothing desired
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deleteByPageAndTransclusionIds).toHaveBeenCalledTimes(1);
|
||||
const [pageId, removedIds] = deleteByPageAndTransclusionIds.mock.calls[0];
|
||||
expect(pageId).toBe('page-1');
|
||||
expect(removedIds).toEqual(['gone']);
|
||||
});
|
||||
|
||||
it('handles a combined insert + update + no-op + delete in one pass', async () => {
|
||||
const same = [{ type: 'keep' }];
|
||||
const { service, insert, update, deleteByPageAndTransclusionIds } =
|
||||
buildTransclusionService([
|
||||
{ transclusionId: 'same', content: snapshotContent(same) }, // unchanged
|
||||
{ transclusionId: 'chg', content: snapshotContent([{ type: 'old' }]) }, // updated
|
||||
{ transclusionId: 'del', content: snapshotContent([]) }, // deleted
|
||||
]);
|
||||
|
||||
const result = await service.syncPageTransclusions(
|
||||
'page-1',
|
||||
'w1',
|
||||
transclusionDoc([
|
||||
{ id: 'same', children: same }, // identical -> no write
|
||||
{ id: 'chg', children: [{ type: 'new' }] }, // changed -> update
|
||||
{ id: 'add', children: [{ type: 'fresh' }] }, // new -> insert
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, updated: 1, deleted: 1 });
|
||||
expect(insert).toHaveBeenCalledTimes(1);
|
||||
expect(insert.mock.calls[0][0].transclusionId).toBe('add');
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update.mock.calls[0][1]).toBe('chg');
|
||||
expect(deleteByPageAndTransclusionIds.mock.calls[0][1]).toEqual(['del']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// syncPageReferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function referenceDoc(
|
||||
refs: Array<{ sourcePageId: string; transclusionId: string }>,
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: refs.map((r) => ({
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: r.sourcePageId, transclusionId: r.transclusionId },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildReferenceService(existing: Array<any>) {
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndKeys = jest.fn().mockResolvedValue(undefined);
|
||||
const findByReferencePageId = jest.fn().mockResolvedValue(existing);
|
||||
|
||||
const pageTransclusionReferencesRepo = {
|
||||
findByReferencePageId,
|
||||
insertMany,
|
||||
deleteByReferenceAndKeys,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
return { service, insertMany, deleteByReferenceAndKeys };
|
||||
}
|
||||
|
||||
describe('TransclusionService.syncPageReferences (diff logic)', () => {
|
||||
it('inserts a new reference keyed by sourcePageId::transclusionId', async () => {
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([{ sourcePageId: 's1', transclusionId: 't1' }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 0 });
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{
|
||||
workspaceId: 'w1',
|
||||
referencePageId: 'ref-page',
|
||||
sourcePageId: 's1',
|
||||
transclusionId: 't1',
|
||||
},
|
||||
]);
|
||||
expect(deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an existing reference absent from the desired set', async () => {
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([
|
||||
{ sourcePageId: 's-gone', transclusionId: 't-gone' },
|
||||
]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndKeys).toHaveBeenCalledTimes(1);
|
||||
const [referencePageId, keys] = deleteByReferenceAndKeys.mock.calls[0];
|
||||
expect(referencePageId).toBe('ref-page');
|
||||
expect(keys).toEqual([
|
||||
{ sourcePageId: 's-gone', transclusionId: 't-gone' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('no-ops when desired and existing already match', async () => {
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([{ sourcePageId: 's1', transclusionId: 't1' }]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([{ sourcePageId: 's1', transclusionId: 't1' }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats two refs differing only in transclusionId as DISTINCT keys', async () => {
|
||||
// existing has (s1,t1). desired keeps (s1,t1) and adds (s1,t2). The two must
|
||||
// not collapse: (s1,t2) is inserted, (s1,t1) untouched, nothing deleted.
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([{ sourcePageId: 's1', transclusionId: 't1' }]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([
|
||||
{ sourcePageId: 's1', transclusionId: 't1' },
|
||||
{ sourcePageId: 's1', transclusionId: 't2' },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 0 });
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{
|
||||
workspaceId: 'w1',
|
||||
referencePageId: 'ref-page',
|
||||
sourcePageId: 's1',
|
||||
transclusionId: 't2',
|
||||
},
|
||||
]);
|
||||
expect(deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('combines insert + delete when the source page of a ref changes', async () => {
|
||||
// existing (s-old,t1); desired (s-new,t1). Different sourcePageId -> distinct
|
||||
// key -> delete the old, insert the new.
|
||||
const { service, insertMany, deleteByReferenceAndKeys } =
|
||||
buildReferenceService([{ sourcePageId: 's-old', transclusionId: 't1' }]);
|
||||
|
||||
const result = await service.syncPageReferences(
|
||||
'ref-page',
|
||||
'w1',
|
||||
referenceDoc([{ sourcePageId: 's-new', transclusionId: 't1' }]),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 1 });
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{
|
||||
workspaceId: 'w1',
|
||||
referencePageId: 'ref-page',
|
||||
sourcePageId: 's-new',
|
||||
transclusionId: 't1',
|
||||
},
|
||||
]);
|
||||
expect(deleteByReferenceAndKeys.mock.calls[0][1]).toEqual([
|
||||
{ sourcePageId: 's-old', transclusionId: 't1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Permission-boundary tests for TransclusionService.unsyncReference.
|
||||
*
|
||||
* unsyncReference converts a `transclusionReference` into a self-contained copy
|
||||
* on the reference page: it copies attachments and deletes the reference row.
|
||||
* It is a write path that must NOT exfiltrate data across workspaces and must
|
||||
* NOT escalate privilege. These tests assert that every guard fires BEFORE any
|
||||
* attachment storage copy / attachment row insert / ref-row delete happens.
|
||||
*
|
||||
* Service is built with the 10 positional constructor args; only the deps each
|
||||
* test touches are real stubs. Real storage is never exercised: content used in
|
||||
* these tests has no attachment nodes, so the attachment-copy block is never
|
||||
* entered on the success-shaped paths, and guard paths throw before it.
|
||||
*
|
||||
* Source order of guards (transclusion.service.ts ~681):
|
||||
* 1. referencePage missing/soft-deleted -> NotFound('Reference page not found')
|
||||
* 2. sourcePage missing/soft-deleted -> NotFound('Source page not found')
|
||||
* 3. either page in a different workspace -> Forbidden
|
||||
* 4. validateCanEdit(referencePage) (may throw -> propagates)
|
||||
* 5. validateCanView(sourcePage)
|
||||
* 6. transclusion row missing -> NotFound('Sync block not found')
|
||||
*/
|
||||
|
||||
const USER_WORKSPACE = 'ws-user';
|
||||
|
||||
function buildService(opts: {
|
||||
pages?: Record<string, any>;
|
||||
validateCanEdit?: jest.Mock;
|
||||
validateCanView?: jest.Mock;
|
||||
transclusion?: any;
|
||||
}) {
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async (id: string) => opts.pages?.[id] ?? null),
|
||||
};
|
||||
const pageAccessService = {
|
||||
validateCanEdit:
|
||||
opts.validateCanEdit ?? jest.fn().mockResolvedValue({ hasRestriction: false }),
|
||||
validateCanView: opts.validateCanView ?? jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const pageTransclusionsRepo = {
|
||||
findByPageAndTransclusion: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.transclusion ?? null),
|
||||
};
|
||||
const pageTransclusionReferencesRepo = {
|
||||
deleteOne: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const attachmentRepo = {
|
||||
findByIds: jest.fn().mockResolvedValue([]),
|
||||
insertAttachment: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const storageService = {
|
||||
copy: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
pageTransclusionsRepo as any,
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
attachmentRepo as any,
|
||||
storageService as any,
|
||||
pageAccessService as any,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
pageRepo,
|
||||
pageAccessService,
|
||||
pageTransclusionsRepo,
|
||||
pageTransclusionReferencesRepo,
|
||||
attachmentRepo,
|
||||
storageService,
|
||||
};
|
||||
}
|
||||
|
||||
const user = { id: 'user-1', workspaceId: USER_WORKSPACE } as any;
|
||||
|
||||
function refPage(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'ref-1',
|
||||
workspaceId: USER_WORKSPACE,
|
||||
spaceId: 'space-ref',
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function srcPage(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 'src-1',
|
||||
workspaceId: USER_WORKSPACE,
|
||||
spaceId: 'space-src',
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TransclusionService.unsyncReference (permission boundary)', () => {
|
||||
it('reference page in a DIFFERENT workspace -> Forbidden before any write or delete', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage({ workspaceId: 'other-ws' }),
|
||||
'src-1': srcPage(),
|
||||
},
|
||||
transclusion: { content: { type: 'doc', content: [] } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
// No attachment copy, no attachment insert, no ref-row delete, and the
|
||||
// edit/view permission checks are never even reached.
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
expect(ctx.pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source page in a DIFFERENT workspace -> Forbidden before any write or delete', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage(),
|
||||
'src-1': srcPage({ workspaceId: 'other-ws' }),
|
||||
},
|
||||
transclusion: { content: { type: 'doc', content: [] } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
expect(ctx.pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reference page missing -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: { 'src-1': srcPage() }, // ref-1 absent
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reference page soft-deleted -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage({ deletedAt: new Date() }),
|
||||
'src-1': srcPage(),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source page missing -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage() }, // src-1 absent
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source page soft-deleted -> NotFound', async () => {
|
||||
const ctx = buildService({
|
||||
pages: {
|
||||
'ref-1': refPage(),
|
||||
'src-1': srcPage({ deletedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateCanEdit(referencePage) throws -> propagates; no attachment copy, ref row NOT deleted', async () => {
|
||||
const editError = new ForbiddenException('no edit');
|
||||
const validateCanEdit = jest.fn().mockRejectedValue(editError);
|
||||
const validateCanView = jest.fn().mockResolvedValue(undefined);
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage(), 'src-1': srcPage() },
|
||||
validateCanEdit,
|
||||
validateCanView,
|
||||
transclusion: { content: { type: 'doc', content: [] } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBe(editError);
|
||||
|
||||
// Edit check fires on the reference page (the write target).
|
||||
expect(validateCanEdit).toHaveBeenCalledTimes(1);
|
||||
expect(validateCanEdit.mock.calls[0][0].id).toBe('ref-1');
|
||||
// View on source never reached, no copy, no insert, no delete.
|
||||
expect(validateCanView).not.toHaveBeenCalled();
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('transclusion row missing -> NotFound("Sync block not found"); no delete', async () => {
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage(), 'src-1': srcPage() },
|
||||
transclusion: null, // findByPageAndTransclusion resolves null
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toMatchObject({ message: 'Sync block not found' });
|
||||
await expect(
|
||||
ctx.service.unsyncReference('ref-1', 'src-1', 't1', user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('happy path with no attachment nodes: deletes the ref row, copies nothing', async () => {
|
||||
// Sanity check that with all guards passing and content carrying no
|
||||
// attachment nodes, the ref row IS deleted and no storage copy happens.
|
||||
const ctx = buildService({
|
||||
pages: { 'ref-1': refPage(), 'src-1': srcPage() },
|
||||
transclusion: {
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ctx.service.unsyncReference(
|
||||
'ref-1',
|
||||
'src-1',
|
||||
't1',
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(ctx.storageService.copy).not.toHaveBeenCalled();
|
||||
expect(ctx.attachmentRepo.insertAttachment).not.toHaveBeenCalled();
|
||||
expect(ctx.pageTransclusionReferencesRepo.deleteOne).toHaveBeenCalledWith(
|
||||
'ref-1',
|
||||
'src-1',
|
||||
't1',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user