test(html-embed): real-execution gate tests for create/duplicate/import (#27)

The create/duplicate/import gate tests asserted gate presence via brittle
expect(SRC).toMatch(/regex/) over the source text plus a reimplemented
applyGate() stand-in, so a refactor could break the real gate while they still
passed. Rewrite both specs to execute the REAL methods (PageService.create /
duplicatePage; ImportService.importPage; FileImportTaskService.processGenericImport)
with each caller role and assert on the PERSISTED content via hasHtmlEmbedNode:
member -> stripped, admin/owner+toggle ON -> preserved, toggle OFF -> stripped
for everyone, unknown/missing role -> fail-closed. No source-regex assertions
remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 22:49:18 +03:00
parent 8ee4279d30
commit 8191c37daa
2 changed files with 458 additions and 177 deletions

View File

@@ -1,25 +1,31 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
// Exercises the REAL PageService htmlEmbed admin gate on its two non-collab
// write paths: PageService.create() and PageService.duplicatePage(). Both build
// content/textContent/ydoc directly and persist, bypassing the collab
// onStoreDocument strip, so each must run the incoming document through the
// toggle-AND-admin gate (`htmlEmbedAllowed(featureEnabled, role)` -> if not
// allowed, `stripHtmlEmbedNodes`) BEFORE persisting.
//
// This spec constructs the REAL PageService with every constructor dep mocked,
// feeds content containing an `htmlEmbed`, calls the real method, and asserts on
// the PERSISTED content (captured at the repo insert / db insert boundary) that
// the embed was actually stripped (member/unknown role) or preserved
// (admin/owner + toggle ON). Mirrors the GOOD pattern in
// transclusion/spec/transclusion-unsync-html-embed.spec.ts.
//
// page.service.ts pulls in the collaboration gateway (a transitive ESM chain
// `lib0/decoding.js` that jest's transformIgnorePatterns does not transpile), so
// that single module is mocked away — it is never used on the create/duplicate
// gate paths.
jest.mock('../../../collaboration/collaboration.gateway', () => ({
CollaborationGateway: class {},
}));
// PageService.create() and duplicatePage() guards.
//
// page.service.ts cannot be unit-LOADED under the server's jest config (a
// transitive ESM dep, @sindresorhus/slugify, is not in transformIgnorePatterns),
// so we cover the two load-bearing properties at the strongest feasible layer:
//
// (1) BEHAVIOR — using the REAL html-embed helpers, replay the exact predicate
// each path applies: non-admin/unknown role -> strip, admin/owner -> keep.
//
// (2) IDENTITY — source-pin which role each path reads (create: the `callerRole`
// param threaded from the request; duplicate: `authUser.role`), so a
// refactor that drops the guard or reads the wrong role trips the test.
// This is what replaces the removed `applyAdminGate` stand-in for these
// two entrypoints.
import { PageService } from './page.service';
import { hasHtmlEmbedNode } from '../../../common/helpers/prosemirror/html-embed.util';
const WS = 'ws-1';
const SPACE = 'space-1';
const USER = 'u1';
const docWithEmbed = () => ({
type: 'doc',
@@ -29,74 +35,206 @@ const docWithEmbed = () => ({
],
});
// The real predicate both paths apply (see SECURITY blocks in page.service.ts):
// toggle AND admin.
function applyGate(
json: any,
featureEnabled: boolean,
role: string | null | undefined,
) {
if (!htmlEmbedAllowed(featureEnabled, role) && hasHtmlEmbedNode(json)) {
return stripHtmlEmbedNodes(json);
}
return json;
// Minimal chainable kysely stub. `nextPagePosition` (used by create) and
// duplicatePage's bulk insert go through `this.db`; only the calls those paths
// make need to resolve. `capturedInserts` collects every page row handed to
// `insertInto('pages').values(...)` so we can assert on the persisted content.
function buildDb(capturedInserts: any[]) {
const selectChain: any = {
select: () => selectChain,
selectAll: () => selectChain,
where: () => selectChain,
orderBy: () => selectChain,
limit: () => selectChain,
execute: async () => [],
executeTakeFirst: async () => undefined,
};
const db: any = {
selectFrom: () => selectChain,
insertInto: (table: string) => ({
values: (rows: any) => {
if (table === 'pages') {
for (const row of Array.isArray(rows) ? rows : [rows]) {
capturedInserts.push(row);
}
}
return { execute: async () => undefined };
},
}),
// executeTx -> db.transaction().execute(cb): run the callback with `db`
// itself acting as the transaction so any in-tx inserts are captured too.
transaction: () => ({ execute: async (cb: any) => cb(db) }),
};
return db;
}
describe('page create/duplicate gate decision (real helpers)', () => {
it('toggle ON + non-admin (member) strips', () => {
const result = applyGate(docWithEmbed(), true, 'member');
expect(hasHtmlEmbedNode(result)).toBe(false);
expect(result.content).toHaveLength(1);
expect(result.content[0].content[0].text).toBe('body');
});
// Build the REAL PageService with all 13 constructor deps mocked. `featureEnabled`
// drives the workspace toggle the gate reads via workspaceRepo.findById.
function buildService(opts: {
featureEnabled: boolean;
capturedInserts: any[];
rootPage?: any; // for duplicatePage
}) {
const { featureEnabled, capturedInserts } = opts;
it('toggle ON + unknown/empty role fails closed (strips)', () => {
for (const role of [null, undefined, 'viewer'] as const) {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, role))).toBe(
false,
);
}
});
const pageRepo: any = {
findById: jest.fn(async () => null), // no parent page in create tests
// create() persists here; capture the row so we can inspect content.
insertPage: jest.fn(async (row: any) => {
capturedInserts.push(row);
return { id: 'new-page', slugId: 'slug-1', ...row };
}),
getPageAndDescendants: jest.fn(async () => [opts.rootPage].filter(Boolean)),
};
it('toggle ON + admin/owner keep', () => {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'admin'))).toBe(
true,
);
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), true, 'owner'))).toBe(
true,
const pagePermissionRepo: any = {
// duplicatePage filters accessible pages; grant the root so it is copied.
filterAccessiblePageIds: jest.fn(async () =>
opts.rootPage ? [opts.rootPage.id] : [],
),
};
const workspaceRepo: any = {
findById: jest.fn(async () => ({
id: WS,
settings: { htmlEmbed: featureEnabled },
})),
};
const attachmentRepo: any = { findByIds: jest.fn(async () => []) };
const storageService: any = { copy: jest.fn(async () => undefined) };
const noopQueue: any = { add: jest.fn(async () => undefined) };
const eventEmitter: any = { emit: jest.fn() };
const collaborationGateway: any = {};
const watcherService: any = {};
// duplicatePage fires transclusion bulk inserts after persisting; they are
// best-effort (wrapped in try/catch) and irrelevant to the gate.
const transclusionService: any = {
insertTransclusionsForPages: jest.fn(async () => undefined),
insertReferencesForPages: jest.fn(async () => undefined),
insertTemplateReferencesForPages: jest.fn(async () => undefined),
};
const db = buildDb(capturedInserts);
const service = new PageService(
pageRepo,
pagePermissionRepo,
attachmentRepo,
db,
storageService,
noopQueue, // attachmentQueue
noopQueue, // aiQueue
noopQueue, // generalQueue
eventEmitter,
collaborationGateway,
watcherService,
transclusionService,
workspaceRepo,
);
return service;
}
describe('PageService.create htmlEmbed admin gate (real code)', () => {
// Run create() and return the content actually persisted via insertPage.
async function persistedContent(
featureEnabled: boolean,
callerRole: string | null | undefined,
) {
const capturedInserts: any[] = [];
const service = buildService({ featureEnabled, capturedInserts });
await service.create(
USER,
WS,
{
spaceId: SPACE,
title: 'p',
// 'json' format is used as-is by parseProsemirrorContent (passed to the
// real jsonToNode schema validation), so hand it the PM-JSON object.
content: docWithEmbed(),
format: 'json' as any,
} as any,
callerRole,
);
expect(capturedInserts).toHaveLength(1);
return capturedInserts[0].content;
}
it('toggle ON + member: persisted content has htmlEmbed stripped', async () => {
const content = await persistedContent(true, 'member');
expect(hasHtmlEmbedNode(content)).toBe(false);
// Non-embed content survives.
expect(JSON.stringify(content)).toContain('body');
});
it('toggle OFF strips for everyone (admin/owner/member)', () => {
for (const role of ['admin', 'owner', 'member'] as const) {
expect(hasHtmlEmbedNode(applyGate(docWithEmbed(), false, role))).toBe(
false,
);
it('toggle ON + admin: persisted content keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, 'admin'))).toBe(true);
});
it('toggle ON + owner: persisted content keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, 'owner'))).toBe(true);
});
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
expect(hasHtmlEmbedNode(await persistedContent(false, 'admin'))).toBe(false);
});
it('unknown/empty role: fails closed (stripped)', async () => {
for (const role of [undefined, null, 'viewer'] as const) {
expect(hasHtmlEmbedNode(await persistedContent(true, role))).toBe(false);
}
});
});
const SRC = readFileSync(join(__dirname, 'page.service.ts'), 'utf-8');
describe('PageService.duplicatePage htmlEmbed admin gate (real code)', () => {
// Duplicate a single source page that contains an embed and return the content
// persisted for the copy (captured at db.insertInto('pages').values(...)).
async function persistedContent(
featureEnabled: boolean,
role: string | null | undefined,
) {
const rootPage: any = {
id: 'src-page',
slugId: 'src-slug',
title: 'Source',
icon: null,
position: 'a0',
spaceId: SPACE,
workspaceId: WS,
parentPageId: null,
content: docWithEmbed(),
};
const capturedInserts: any[] = [];
const service = buildService({ featureEnabled, capturedInserts, rootPage });
const authUser: any = { id: USER, workspaceId: WS, role };
await service.duplicatePage(rootPage, undefined, authUser);
// The bulk insert is the page persist boundary; one source page -> one copy.
const pageRows = capturedInserts.filter((r) => r.content);
expect(pageRows.length).toBeGreaterThanOrEqual(1);
return pageRows[0].content;
}
describe('page create/duplicate gate identity is pinned (source contract)', () => {
it('create() gates on toggle AND the caller role param before deriving content/ydoc', () => {
// create() receives the caller's workspace role as `callerRole` and gates on
// the combined toggle-AND-admin predicate; the embed must be stripped BEFORE
// insertPage.
expect(SRC).toMatch(
/!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*callerRole\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
);
expect(SRC).toContain('prosemirrorJson = stripHtmlEmbedNodes(prosemirrorJson)');
it('toggle ON + member: persisted copy has htmlEmbed stripped', async () => {
const content = await persistedContent(true, 'member');
expect(hasHtmlEmbedNode(content)).toBe(false);
expect(JSON.stringify(content)).toContain('body');
});
it('duplicatePage() gates on toggle AND the duplicating user role (authUser.role)', () => {
expect(SRC).toMatch(
/!htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*authUser\.role\s*\)\s*&&\s*hasHtmlEmbedNode\(\s*prosemirrorJson\s*\)/,
);
it('toggle ON + admin: persisted copy keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, 'admin'))).toBe(true);
});
it('both paths resolve the toggle from the workspace settings', () => {
expect(SRC).toContain('isHtmlEmbedFeatureEnabled(');
expect(SRC).toContain('this.workspaceRepo.findById(');
it('toggle ON + owner: persisted copy keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, 'owner'))).toBe(true);
});
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
expect(hasHtmlEmbedNode(await persistedContent(false, 'admin'))).toBe(false);
});
it('unknown/empty role: fails closed (stripped)', async () => {
for (const role of [undefined, null, 'viewer'] as const) {
expect(hasHtmlEmbedNode(await persistedContent(true, role))).toBe(false);
}
});
});

View File

@@ -1,123 +1,266 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
hasHtmlEmbedNode,
htmlEmbedAllowed,
stripHtmlEmbedNodes,
} from '../../../common/helpers/prosemirror/html-embed.util';
// Exercises the REAL htmlEmbed admin gate on the two import write paths:
//
// (1) ImportService.importPage() — single .html/.md upload
// (2) FileImportTaskService.processGenericImport() — zip / multi-file import
//
// Both build content/textContent/ydoc directly and persist (bypassing the
// collab onStoreDocument strip), so each must run the imported document through
// the toggle-AND-admin gate: resolve the importer via userRepo.findById, read
// the workspace toggle, then `htmlEmbedAllowed(enabled, role)` -> if not allowed,
// `stripHtmlEmbedNodes` BEFORE persisting.
//
// This spec constructs the REAL services with deps mocked, feeds an imported
// HTML document that contains an `htmlEmbed` div (parsed into a real htmlEmbed
// node by the REAL htmlToJson), runs the real method, and asserts the PERSISTED
// content (captured at the insert boundary) is stripped for a non-admin /
// missing user and preserved for admin/owner + toggle ON. Mirrors the GOOD
// pattern in transclusion/spec/transclusion-unsync-html-embed.spec.ts.
//
// Three modules are mocked away because they pull transitive ESM deps that
// jest's transformIgnorePatterns does not transpile (`lib0/decoding.js` via the
// collab gateway, `@sindresorhus/slugify` via import-formatter, `p-limit` via
// import-attachment). None of them participate in the gate decision:
// - import-formatter: contextless HTML cleanup + link rewriting; replaced with
// faithful passthroughs (the embed div has no href/iframe, so the real
// normalizer would leave it untouched anyway).
// - import-attachment: attachment rewriting; passthrough returns html as-is.
jest.mock('../../../collaboration/collaboration.gateway', () => ({
CollaborationGateway: class {},
}));
jest.mock('../utils/import-formatter', () => ({
normalizeImportHtml: () => {},
formatImportHtml: async (opts: any) => ({
html: opts.html,
backlinks: [],
pageIcon: undefined,
}),
}));
jest.mock('./import-attachment.service', () => ({
ImportAttachmentService: class {},
}));
// FAIL-CLOSED IDENTITY for the import write paths.
//
// import.service / file-import-task.service cannot be unit-LOADED under the
// server's jest config (a transitive ESM dep, @sindresorhus/slugify, is not in
// transformIgnorePatterns). So we cover the two load-bearing properties at the
// strongest feasible layer:
//
// (1) BEHAVIOR — using the REAL html-embed helpers, replay the exact gate
// predicate each entrypoint runs against the role resolved from
// userRepo.findById(...): a MISSING user (findById -> undefined) must fail
// closed (strip), and only 'admin'/'owner' keep the embed.
//
// (2) IDENTITY — source-pin which identity governs the gate so a refactor that
// swaps the lookup to the wrong user (e.g. the queue worker's caller) is
// caught: zip import resolves the role from `fileTask.creatorId`; single
// import from the request `userId`. NOT some ambient caller.
//
// If a guard is deleted/misplaced or the identity field changes, these break.
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ImportService } from './import.service';
import { FileImportTaskService } from './file-import-task.service';
import { hasHtmlEmbedNode } from '../../../common/helpers/prosemirror/html-embed.util';
const docWithEmbed = () => ({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'imported body' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
],
});
const WS = 'ws-1';
const SPACE = 'space-1';
const USER = 'importer-1';
// The real predicate both import entrypoints apply (see the SECURITY blocks in
// import.service.ts and file-import-task.service.ts): resolve the importer via
// userRepo.findById, then `!canAuthorHtmlEmbed(role) && hasHtmlEmbedNode(json)`.
function applyImportGate(
json: any,
featureEnabled: boolean,
importingUser: { role?: string } | undefined,
) {
if (
!htmlEmbedAllowed(featureEnabled, importingUser?.role) &&
hasHtmlEmbedNode(json)
) {
return stripHtmlEmbedNodes(json);
}
return json;
// HTML carrying the serialized htmlEmbed node. The REAL htmlToJson parses
// `<div data-type="htmlEmbed" data-source="BASE64">` into an htmlEmbed PM node
// (base64 below decodes to `<script>x</script>`).
const HTML_WITH_EMBED =
'<p>imported body</p>' +
'<div data-type="htmlEmbed" data-source="PHNjcmlwdD54PC9zY3JpcHQ+"></div>';
function workspaceRepoFor(featureEnabled: boolean) {
return {
findById: jest.fn(async () => ({
id: WS,
settings: { htmlEmbed: featureEnabled },
})),
};
}
describe('import gate fail-closed by toggle AND resolved-user role (real helpers)', () => {
it('toggle ON + missing user (userRepo.findById -> undefined) strips the embed', () => {
// findById returns undefined when the user/workspace pair does not resolve;
// undefined?.role is undefined -> htmlEmbedAllowed(true, undefined) === false.
const result = applyImportGate(docWithEmbed(), true, undefined);
expect(hasHtmlEmbedNode(result)).toBe(false);
// userRepo.findById resolves the importer's role (or undefined for a missing
// user -> fail closed).
function userRepoFor(user: { role?: string } | undefined) {
return { findById: jest.fn(async () => user) };
}
describe('ImportService.importPage htmlEmbed admin gate (real code)', () => {
// Run importPage with a single uploaded .html and return the persisted content
// captured at pageRepo.insertPage.
async function persistedContent(
featureEnabled: boolean,
user: { role?: string } | undefined,
) {
const captured: any[] = [];
const pageRepo: any = {
insertPage: jest.fn(async (row: any) => {
captured.push(row);
return { id: 'p1', slugId: 's1', ...row };
}),
};
// db is only used for getNewPagePosition (a select chain).
const selectChain: any = {
select: () => selectChain,
where: () => selectChain,
orderBy: () => selectChain,
limit: () => selectChain,
executeTakeFirst: async () => undefined,
};
const db: any = { selectFrom: () => selectChain };
const service = new ImportService(
pageRepo,
userRepoFor(user) as any,
{ putBuffer: jest.fn() } as any, // storageService (unused on this path)
db,
{ add: jest.fn() } as any, // fileTaskQueue (unused)
workspaceRepoFor(featureEnabled) as any,
);
const file: any = {
filename: 'doc.html',
toBuffer: async () => Buffer.from(HTML_WITH_EMBED, 'utf-8'),
};
await service.importPage(Promise.resolve(file), USER, SPACE, WS);
expect(captured).toHaveLength(1);
return captured[0].content;
}
it('toggle ON + member: persisted content has htmlEmbed stripped', async () => {
const content = await persistedContent(true, { role: 'member' });
expect(hasHtmlEmbedNode(content)).toBe(false);
expect(JSON.stringify(content)).toContain('imported body');
});
it("toggle ON + resolved role 'member' strips", () => {
it('toggle ON + missing user (findById -> undefined): fails closed (stripped)', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, undefined))).toBe(
false,
);
});
it('toggle ON + admin: persisted content keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, { role: 'admin' }))).toBe(
true,
);
});
it('toggle ON + owner: persisted content keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, { role: 'owner' }))).toBe(
true,
);
});
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'member' })),
hasHtmlEmbedNode(await persistedContent(false, { role: 'admin' })),
).toBe(false);
});
it("toggle ON + resolved role 'admin' keeps the embed", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'admin' })),
).toBe(true);
});
it("toggle ON + resolved role 'owner' keeps the embed", () => {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), true, { role: 'owner' })),
).toBe(true);
});
it('toggle OFF strips for every role (admin/owner/member)', () => {
for (const role of ['admin', 'owner', 'member'] as const) {
expect(
hasHtmlEmbedNode(applyImportGate(docWithEmbed(), false, { role })),
).toBe(false);
}
});
});
// Source-pin the identity each entrypoint feeds to userRepo.findById. These are
// the lines that decide WHOSE role governs the gate; pinning them means a
// refactor that points the lookup at the wrong user trips the test.
const SRC_DIR = join(__dirname);
describe('FileImportTaskService.processGenericImport htmlEmbed admin gate (real code)', () => {
let extractDir: string;
describe('import gate identity is pinned to the importer (source contract)', () => {
it('single import resolves the role from the request userId', () => {
const src = readFileSync(join(SRC_DIR, 'import.service.ts'), 'utf-8');
// The role lookup must key on the request `userId`, then gate on the role.
expect(src).toMatch(
/this\.userRepo\.findById\(\s*userId\s*,\s*workspaceId\s*\)/,
);
expect(src).toMatch(
/htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*\)/,
);
// And the toggle is resolved from the workspace settings.
expect(src).toContain('isHtmlEmbedFeatureEnabled(');
// And the gate uses the real strip helper.
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
beforeEach(async () => {
// Real temp dir holding a single .html page that carries the embed; the
// method reads it from disk via fs.readFile.
extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'html-embed-import-'));
await fs.writeFile(path.join(extractDir, 'page.html'), HTML_WITH_EMBED);
});
it('zip import resolves the role from fileTask.creatorId (NOT the queue caller)', () => {
const src = readFileSync(
join(SRC_DIR, 'file-import-task.service.ts'),
'utf-8',
afterEach(async () => {
await fs.rm(extractDir, { recursive: true, force: true });
});
// Run processGenericImport over the temp dir and return the content persisted
// for the imported page (captured at trx.insertInto('pages').values(...)).
async function persistedContent(
featureEnabled: boolean,
user: { role?: string } | undefined,
) {
const captured: any[] = [];
const trxInsertChain = (table: string) => ({
values: (row: any) => {
if (table === 'pages') captured.push(row);
return { execute: async () => undefined };
},
});
const trx: any = { insertInto: trxInsertChain };
const db: any = {
// spaces lookup at the top of processGenericImport
selectFrom: () => ({
select: () => ({
where: () => ({ executeTakeFirst: async () => ({ slug: 'sp' }) }),
}),
}),
// executeTx -> db.transaction().execute(cb)
transaction: () => ({ execute: async (cb: any) => cb(trx) }),
};
// importService stub: only the real, gate-relevant helpers are used. We give
// it the REAL implementations by delegating to a real ImportService for
// processHTML/extractTitleAndRemoveHeading/createYdoc so the embed parse and
// strip path runs for real.
const realImport = new ImportService(
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
expect(src).toMatch(
/this\.userRepo\.findById\(\s*fileTask\.creatorId\s*,\s*fileTask\.workspaceId\s*,?\s*\)/,
const importService: any = {
processHTML: (html: string) => realImport.processHTML(html),
extractTitleAndRemoveHeading: (s: any) =>
realImport.extractTitleAndRemoveHeading(s),
createYdoc: (j: any) => realImport.createYdoc(j),
};
const importAttachmentService: any = {
// passthrough: no attachment rewriting, return html unchanged
processAttachments: jest.fn(async (opts: any) => opts.html),
};
const service = new FileImportTaskService(
{ putBuffer: jest.fn() } as any, // storageService
importService,
{ nextPagePosition: jest.fn(async () => 'a0') } as any, // pageService (position only)
{ insertBacklink: jest.fn() } as any, // backlinkRepo
db,
importAttachmentService,
userRepoFor(user) as any,
workspaceRepoFor(featureEnabled) as any,
{ emit: jest.fn() } as any, // eventEmitter
{ logBatchWithContext: jest.fn() } as any, // auditService
);
expect(src).toMatch(
/importerCanAuthorHtmlEmbed\s*=\s*htmlEmbedAllowed\(\s*htmlEmbedEnabled\s*,\s*importingUser\?\.role\s*,?\s*\)/,
const fileTask: any = {
id: 'task-1',
creatorId: USER,
workspaceId: WS,
spaceId: SPACE,
source: 'generic',
};
await service.processGenericImport({ extractDir, fileTask });
expect(captured.length).toBeGreaterThanOrEqual(1);
return captured[0].content;
}
it('toggle ON + member: persisted page has htmlEmbed stripped', async () => {
const content = await persistedContent(true, { role: 'member' });
expect(hasHtmlEmbedNode(content)).toBe(false);
expect(JSON.stringify(content)).toContain('imported body');
});
it('toggle ON + missing user (creatorId resolves to undefined): fails closed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, undefined))).toBe(
false,
);
expect(src).toContain('isHtmlEmbedFeatureEnabled(');
expect(src).toContain('stripHtmlEmbedNodes(prosemirrorJson)');
});
it('toggle ON + admin: persisted page keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, { role: 'admin' }))).toBe(
true,
);
});
it('toggle ON + owner: persisted page keeps the htmlEmbed', async () => {
expect(hasHtmlEmbedNode(await persistedContent(true, { role: 'owner' }))).toBe(
true,
);
});
it('toggle OFF + admin: stripped (feature disabled for everyone)', async () => {
expect(
hasHtmlEmbedNode(await persistedContent(false, { role: 'admin' })),
).toBe(false);
});
});