diff --git a/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts b/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts index f23d565a..bc1b8254 100644 --- a/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts +++ b/apps/server/src/core/page/services/page-service-html-embed-identity.spec.ts @@ -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); + } }); }); diff --git a/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts b/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts index 603765fc..d2902be0 100644 --- a/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts +++ b/apps/server/src/integrations/import/services/import-html-embed-identity.spec.ts @@ -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: '' } }, - ], -}); +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 +// `
` into an htmlEmbed PM node +// (base64 below decodes to ``). +const HTML_WITH_EMBED = + '

imported body

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