@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user