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:
claude_code
2026-06-21 18:40:07 +03:00
parent 0b2af34029
commit 4df79aafd3
18 changed files with 3846 additions and 0 deletions
@@ -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',
);
});
});