test: review-batch-2 follow-up coverage (sandbox html-embed, #101 fixes, i18n) #110

Merged
vvzvlad merged 12 commits from test/review-batch-2-followups into develop 2026-06-21 05:55:11 +03:00
27 changed files with 1055 additions and 70 deletions

View File

@@ -668,6 +668,8 @@
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI agent": "AI-агент",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Thinking": "Думаю",

View File

@@ -5,6 +5,7 @@ import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -67,7 +68,7 @@ export default function MessageItem({
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{assistantName?.trim() || t("AI agent")}
{resolveAssistantName(assistantName) ?? t("AI agent")}
</Text>
{message.parts.map((part, index) => {
if (part.type === "text") {

View File

@@ -1,5 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface TypingIndicatorProps {
@@ -23,12 +24,12 @@ interface TypingIndicatorProps {
*/
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
const { t } = useTranslation();
const name = assistantName?.trim();
const name = resolveAssistantName(assistantName);
return (
<Box className={classes.messageRow}>
<Text size="xs" c="dimmed" mb={4}>
{name || t("AI agent")}
{name ?? t("AI agent")}
</Text>
<Group gap={8} align="center">
<span className={classes.typingDots} aria-hidden="true">

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { resolveAssistantName } from "./assistant-name";
describe("resolveAssistantName", () => {
it("returns a real name unchanged", () => {
expect(resolveAssistantName("Ada")).toBe("Ada");
});
it("trims surrounding whitespace from a real name", () => {
expect(resolveAssistantName(" Ada ")).toBe("Ada");
});
it("returns null for a whitespace-only name (the reason for .trim())", () => {
expect(resolveAssistantName(" ")).toBeNull();
});
it("returns null when the name is undefined", () => {
expect(resolveAssistantName(undefined)).toBeNull();
});
it("returns null for an empty string", () => {
expect(resolveAssistantName("")).toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
// Pure helper for resolving the assistant's display name. Kept free of React so
// it can be unit-tested in isolation (see assistant-name.test.ts) and shared by
// the components that render the assistant identity (TypingIndicator, MessageItem).
/**
* Resolve the assistant's display name from the optional configured identity.
*
* Returns the trimmed name when it has visible (non-whitespace) characters, or
* `null` when the name is absent or whitespace-only. Callers fall back to a
* generic "AI agent" label on `null`. The `.trim()` is why a name of " " must
* resolve to `null` rather than rendering an empty label.
*/
export function resolveAssistantName(assistantName?: string): string | null {
const name = assistantName?.trim();
return name ? name : null;
}

View File

@@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest";
import {
buildSandboxSrcdoc,
canEdit,
clampHeight,
HTML_EMBED_HEIGHT_MESSAGE,
HTML_EMBED_SANDBOX,
isTrustedHeightMessage,
MAX_IFRAME_HEIGHT,
MIN_IFRAME_HEIGHT,
shouldRender,
} from "./html-embed-sandbox";
@@ -48,6 +53,108 @@ describe("shouldRender (render policy)", () => {
});
});
describe("clampHeight", () => {
it("clamps below the lower bound up to MIN_IFRAME_HEIGHT", () => {
expect(clampHeight(0)).toBe(MIN_IFRAME_HEIGHT);
expect(clampHeight(-100)).toBe(MIN_IFRAME_HEIGHT);
expect(clampHeight(MIN_IFRAME_HEIGHT - 1)).toBe(MIN_IFRAME_HEIGHT);
});
it("clamps above the upper bound down to MAX_IFRAME_HEIGHT", () => {
expect(clampHeight(MAX_IFRAME_HEIGHT + 1)).toBe(MAX_IFRAME_HEIGHT);
expect(clampHeight(999999)).toBe(MAX_IFRAME_HEIGHT);
});
it("passes a value within range through unchanged", () => {
expect(clampHeight(150)).toBe(150);
expect(clampHeight(MIN_IFRAME_HEIGHT)).toBe(MIN_IFRAME_HEIGHT);
expect(clampHeight(MAX_IFRAME_HEIGHT)).toBe(MAX_IFRAME_HEIGHT);
});
});
describe("isTrustedHeightMessage (resize message guard)", () => {
// Stand-ins for window objects; identity is all the guard compares.
const ownWindow = {} as Window;
const foreignWindow = {} as Window;
const iframeEl = { contentWindow: ownWindow };
const validData = { type: HTML_EMBED_HEIGHT_MESSAGE, height: 300 };
it("accepts a same-source message with a finite numeric height", () => {
expect(
isTrustedHeightMessage({ source: ownWindow, data: validData }, iframeEl),
).toBe(true);
});
it("rejects a message from a DIFFERENT source (foreign window)", () => {
// A page can postMessage anything; only our own iframe's contentWindow is
// trusted. This is the core security check.
expect(
isTrustedHeightMessage(
{ source: foreignWindow, data: validData },
iframeEl,
),
).toBe(false);
});
it("rejects a wrong-type message even from the right source", () => {
expect(
isTrustedHeightMessage(
{ source: ownWindow, data: { type: "something-else", height: 300 } },
iframeEl,
),
).toBe(false);
});
it("rejects a NaN height", () => {
expect(
isTrustedHeightMessage(
{ source: ownWindow, data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: NaN } },
iframeEl,
),
).toBe(false);
});
it("rejects an Infinity height", () => {
expect(
isTrustedHeightMessage(
{
source: ownWindow,
data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: Infinity },
},
iframeEl,
),
).toBe(false);
});
it("rejects when the iframe element / contentWindow is null", () => {
expect(
isTrustedHeightMessage({ source: ownWindow, data: validData }, null),
).toBe(false);
expect(
isTrustedHeightMessage(
{ source: null, data: validData },
{ contentWindow: null },
),
).toBe(false);
});
});
describe("iframe sandbox attributes", () => {
it("uses EXACTLY allow-scripts allow-popups allow-forms (no allow-same-origin)", () => {
expect(HTML_EMBED_SANDBOX).toBe("allow-scripts allow-popups allow-forms");
// The critical security invariant: opaque origin => no session/cookie access.
expect(HTML_EMBED_SANDBOX).not.toContain("allow-same-origin");
});
it("the NodeView renders the embed via srcDoc (not src), set to the sandbox doc", () => {
// The iframe carries the generated srcdoc; it never loads an external URL.
const srcdoc = buildSandboxSrcdoc("<p>hi</p>");
expect(srcdoc).toContain("<p>hi</p>");
expect(srcdoc).toContain(HTML_EMBED_HEIGHT_MESSAGE);
});
});
describe("canEdit (edit policy)", () => {
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
expect(canEdit(true, true)).toBe(true);

View File

@@ -7,6 +7,48 @@
/** postMessage type the sandboxed iframe uses to report its content height. */
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
// page layout, and a sensible default before the first height message arrives.
export const MIN_IFRAME_HEIGHT = 40;
export const MAX_IFRAME_HEIGHT = 4000;
export const DEFAULT_IFRAME_HEIGHT = 150;
/**
* Sandbox tokens for the embed iframe. Intentionally does NOT include
* `allow-same-origin`: the content must run in an opaque ("null") origin so it
* cannot read the viewer's cookies/session/API.
*/
export const HTML_EMBED_SANDBOX = "allow-scripts allow-popups allow-forms";
/** Clamp a reported/configured height into the sane iframe bounds. */
export function clampHeight(h: number): number {
return Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
}
/**
* Guard for the auto-resize `message` handler. Returns the clamped numeric
* height ONLY when the event is a trusted resize report; otherwise null.
*
* Trusted means ALL of:
* - `event.source` is this iframe's own `contentWindow` (the sandboxed srcdoc
* has an opaque "null" origin, so we cannot match by `event.origin` — we
* match by source instead). A message from any OTHER window is rejected.
* - the payload `type` is exactly our agreed resize message type.
* - the reported `height` is a finite number (rejects NaN/Infinity).
*/
export function isTrustedHeightMessage(
event: Pick<MessageEvent, "source" | "data">,
iframeEl: { contentWindow: Window | null } | null,
): boolean {
// Reject when there is no contentWindow to match against; otherwise a `null`
// event.source would spuriously equal a `null` contentWindow.
if (!iframeEl?.contentWindow) return false;
if (event.source !== iframeEl.contentWindow) return false;
const data = event.data as { type?: string; height?: number } | null;
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return false;
return Number.isFinite(Number(data.height));
}
/**
* Build the `srcdoc` document for the sandboxed embed iframe.
*

View File

@@ -24,20 +24,15 @@ import classes from "./html-embed-view.module.css";
import {
buildSandboxSrcdoc,
canEdit as computeCanEdit,
HTML_EMBED_HEIGHT_MESSAGE,
clampHeight,
DEFAULT_IFRAME_HEIGHT,
HTML_EMBED_SANDBOX,
isTrustedHeightMessage,
MAX_IFRAME_HEIGHT,
MIN_IFRAME_HEIGHT,
shouldRender as computeShouldRender,
} from "./html-embed-sandbox.ts";
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
// page layout, and a sensible default before the first height message arrives.
const MIN_IFRAME_HEIGHT = 40;
const MAX_IFRAME_HEIGHT = 4000;
const DEFAULT_IFRAME_HEIGHT = 150;
// Clamp a reported/configured height into the sane iframe bounds.
const clampHeight = (h: number) =>
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
@@ -81,11 +76,8 @@ export default function HtmlEmbedView(props: NodeViewProps) {
// auto shows the current content height with no iframe reload.
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data as { type?: string; height?: number };
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
const next = Number(data.height);
if (!Number.isFinite(next)) return;
if (!isTrustedHeightMessage(event, iframeRef.current)) return;
const next = Number((event.data as { height?: number }).height);
setAutoHeight(clampHeight(next));
}
window.addEventListener("message", onMessage);
@@ -153,7 +145,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
<iframe
ref={iframeRef}
className={classes.htmlEmbedFrame}
sandbox="allow-scripts allow-popups allow-forms"
sandbox={HTML_EMBED_SANDBOX}
srcDoc={srcdoc}
title={t("HTML embed")}
referrerPolicy="no-referrer"

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getSuggestionItems,
isHtmlEmbedFeatureEnabled,
} from "./menu-items";
// Gating coverage for the workspace-level "HTML embed" slash item. The gate is
// read from the persisted `currentUser` localStorage entry (the same payload
// `currentUserAtom` writes). It must default to OFF, only show when the toggle
// is explicitly true, and never throw on a broken/garbage stored value.
const KEY = "currentUser";
function setCurrentUser(value: unknown): void {
localStorage.setItem(KEY, JSON.stringify(value));
}
afterEach(() => {
localStorage.clear();
});
describe("isHtmlEmbedFeatureEnabled (workspace toggle gate)", () => {
it("is OFF when no currentUser is persisted (default)", () => {
localStorage.removeItem(KEY);
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
});
it("is OFF when the toggle is absent from workspace settings", () => {
setCurrentUser({ workspace: { settings: {} } });
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
});
it("is OFF when the toggle is explicitly false", () => {
setCurrentUser({ workspace: { settings: { htmlEmbed: false } } });
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
});
it("is ON only when the toggle is exactly true", () => {
setCurrentUser({ workspace: { settings: { htmlEmbed: true } } });
expect(isHtmlEmbedFeatureEnabled()).toBe(true);
});
it("does not throw and returns false on a broken localStorage value", () => {
// Invalid JSON: JSON.parse throws; the gate must swallow it -> false.
localStorage.setItem(KEY, "{not valid json");
expect(() => isHtmlEmbedFeatureEnabled()).not.toThrow();
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
});
});
function hasHtmlEmbedItem(query = "html"): boolean {
const groups = getSuggestionItems({ query });
return Object.values(groups)
.flat()
.some((item) => item.title === "HTML embed");
}
describe("getSuggestionItems — HTML embed item gating", () => {
it("hides the HTML embed item when the toggle is OFF (default)", () => {
localStorage.removeItem(KEY);
expect(hasHtmlEmbedItem()).toBe(false);
});
it("hides the HTML embed item when the toggle is explicitly false", () => {
setCurrentUser({ workspace: { settings: { htmlEmbed: false } } });
expect(hasHtmlEmbedItem()).toBe(false);
});
it("shows the HTML embed item when the toggle is ON", () => {
setCurrentUser({ workspace: { settings: { htmlEmbed: true } } });
expect(hasHtmlEmbedItem()).toBe(true);
});
it("hides the item without throwing on a broken localStorage value", () => {
localStorage.setItem(KEY, "{not valid json");
expect(() => getSuggestionItems({ query: "html" })).not.toThrow();
expect(hasHtmlEmbedItem()).toBe(false);
});
});

View File

@@ -801,7 +801,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
* the persisted state directly. UI gate only; an anonymous public-share read is
* served already-stripped content by the server when the toggle is OFF.
*/
function isHtmlEmbedFeatureEnabled(): boolean {
export function isHtmlEmbedFeatureEnabled(): boolean {
try {
const raw = localStorage.getItem("currentUser");
if (!raw) return false;

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import {
makeConnectHandler,
shouldResyncOnConnect,
ROOT_SIDEBAR_PAGES_KEY,
SIDEBAR_PAGES_KEY,
} from "./connect-resync";
describe("shouldResyncOnConnect", () => {
it("does not resync on the first connect", () => {
expect(shouldResyncOnConnect(true)).toBe(false);
});
it("resyncs on a reconnect (not the first connect)", () => {
expect(shouldResyncOnConnect(false)).toBe(true);
});
});
describe("makeConnectHandler", () => {
it("does NOT invalidate on the first connect", () => {
const invalidateQueries = vi.fn();
const handler = makeConnectHandler({ invalidateQueries });
handler();
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("invalidates BOTH sidebar keys on the reconnect (second connect)", () => {
const invalidateQueries = vi.fn();
const handler = makeConnectHandler({ invalidateQueries });
// First connect: the initial connection, no resync.
handler();
expect(invalidateQueries).not.toHaveBeenCalled();
// Second connect: a reconnect after a gap, resync both tree levels.
handler();
expect(invalidateQueries).toHaveBeenCalledTimes(2);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: [...ROOT_SIDEBAR_PAGES_KEY],
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: [...SIDEBAR_PAGES_KEY],
});
});
it("keeps invalidating on every subsequent reconnect", () => {
const invalidateQueries = vi.fn();
const handler = makeConnectHandler({ invalidateQueries });
handler(); // first connect -> nothing
handler(); // reconnect #1 -> 2 calls
handler(); // reconnect #2 -> 2 more calls
expect(invalidateQueries).toHaveBeenCalledTimes(4);
});
it("isolates state per handler instance (each factory call gets its own flag)", () => {
const invalidateA = vi.fn();
const invalidateB = vi.fn();
const handlerA = makeConnectHandler({ invalidateQueries: invalidateA });
const handlerB = makeConnectHandler({ invalidateQueries: invalidateB });
// Exhausting handlerA's first connect must not affect handlerB.
handlerA();
handlerA(); // reconnect on A
handlerB(); // still A's-independent first connect on B
expect(invalidateA).toHaveBeenCalledTimes(2);
expect(invalidateB).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import type { QueryClient } from "@tanstack/react-query";
// Sidebar tree query keys that must be refetched (through the authorized API)
// after a socket reconnect so the view re-converges after a gap where ws events
// were missed (wifi blip, laptop sleep). Both the root level and the
// nested-page levels of every space tree are invalidated.
export const ROOT_SIDEBAR_PAGES_KEY = ["root-sidebar-pages"] as const;
export const SIDEBAR_PAGES_KEY = ["sidebar-pages"] as const;
/**
* Pure decision for the reconnect-resync branch.
*
* The first `connect` event is the initial connection and must NOT trigger a
* resync (the data was just fetched). Every subsequent `connect` event is a
* RECONNECT after a gap and should trigger a resync.
*/
export function shouldResyncOnConnect(isFirstConnect: boolean): boolean {
return !isFirstConnect;
}
/**
* Build the socket `connect` handler that owns the first-connect-vs-reconnect
* logic via a private closure flag. The returned handler is what the component
* registers with `socket.on("connect", ...)`.
*
* - 1st invocation -> first connect, no invalidation.
* - 2nd+ invocation -> reconnect, invalidate both sidebar tree key levels.
*/
export function makeConnectHandler(
queryClient: Pick<QueryClient, "invalidateQueries">,
): () => void {
let firstConnect = true;
return () => {
if (shouldResyncOnConnect(firstConnect)) {
queryClient.invalidateQueries({ queryKey: [...ROOT_SIDEBAR_PAGES_KEY] });
queryClient.invalidateQueries({ queryKey: [...SIDEBAR_PAGES_KEY] });
}
firstConnect = false;
};
}

View File

@@ -12,6 +12,7 @@ import { useNotificationSocket } from "@/features/notification/hooks/use-notific
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -34,19 +35,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// @ts-ignore
setSocket(newSocket);
// Distinguish the first connect from a reconnect so we only resync after a gap.
let firstConnect = true;
// Distinguish the first connect from a reconnect so we only resync after a
// gap. The handler owns the first-connect-vs-reconnect decision through a
// private closure flag (see makeConnectHandler): on RECONNECT it refetches
// the sidebar tree through the authorized API so the view re-converges after
// a gap where ws events were missed (wifi blip, laptop sleep), invalidating
// both the root level and the nested-page levels of every space tree.
const handleConnect = makeConnectHandler(queryClient);
newSocket.on("connect", () => {
console.log("ws connected");
if (!firstConnect) {
// On RECONNECT (not the first connect) refetch the sidebar tree through the
// authorized API so the view re-converges after a gap where ws events were
// missed (wifi blip, laptop sleep). Invalidate both the root level and the
// nested-page levels of every space tree.
queryClient.invalidateQueries({ queryKey: ["root-sidebar-pages"] });
queryClient.invalidateQueries({ queryKey: ["sidebar-pages"] });
}
firstConnect = false;
handleConnect();
});
return () => {

View File

@@ -209,6 +209,56 @@ describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(413);
});
it('a message with a non-text part => 400 Unsupported message content', async () => {
// The anonymous path runs no tools, so a client-supplied tool/file/data part
// is never legitimate and is rejected before it can reach the model context.
const { deps } = makeDeps();
const nonText = {
role: 'user',
parts: [{ type: 'tool-call' }],
};
let caught: HttpException | null = null;
try {
await resolveShareAssistantRequest(deps, {
workspaceId: 'ws-1',
body: body({ messages: [nonText] }) as never,
});
} catch (err) {
caught = err instanceof HttpException ? err : null;
}
expect(caught).toBeInstanceOf(HttpException);
expect(caught!.getStatus()).toBe(400);
expect(caught!.message).toBe('Unsupported message content');
});
it('a message mixing a text part AND a non-text part => still 400 (rejected before the 413 size check)', async () => {
// A forged non-text part smuggled alongside a legit text part is still
// rejected: the non-text guard runs BEFORE the char-cap (413) check, so even
// an over-long mixed message surfaces the 400, not the size error.
const { deps } = makeDeps();
const mixed = {
role: 'user',
parts: [
{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) },
{ type: 'tool-call' },
],
};
let caught: HttpException | null = null;
try {
await resolveShareAssistantRequest(deps, {
workspaceId: 'ws-1',
body: body({ messages: [mixed] }) as never,
});
} catch (err) {
caught = err instanceof HttpException ? err : null;
}
expect(caught).toBeInstanceOf(HttpException);
// The non-text guard wins over the 413 size cap even though the text part
// alone would exceed MAX_SHARE_MESSAGE_CHARS.
expect(caught!.getStatus()).toBe(400);
expect(caught!.message).toBe('Unsupported message content');
});
it('the quota gate is checked BEFORE the payload caps (429 wins over 413)', async () => {
// Over-cap workspace AND an over-long message: the 429 must surface first, so
// an over-cap caller is rejected without even paying the payload-cap scan.

View File

@@ -1,4 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { PageService } from './page.service';
import { MovePageDto } from '../dto/move-page.dto';
import { Page } from '@docmost/db/types/entity.types';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
@@ -26,4 +29,122 @@ describe('PageService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('movePage cycle guard (#67)', () => {
// A valid fractional-indexing key — movePage validates `position` by feeding
// it to generateJitteredKeyBetween(position, null) before anything else.
const VALID_POSITION = 'a0';
const SPACE_ID = 'space-1';
// Build a PageService whose pageRepo (findById/updatePage) and own
// getPageBreadCrumbs are mockable, while every other collaborator stays a
// bare stub. We only need to drive the three cycle-guard branches, so we
// mock minimally rather than standing up the whole DI graph.
const makeService = (overrides?: {
breadcrumbs?: Array<{ id: string }>;
}) => {
const pageRepo = {
// Destination parent lookup: a valid, non-deleted, same-space page.
findById: jest.fn().mockResolvedValue({
id: 'dest-parent',
deletedAt: null,
spaceId: SPACE_ID,
}),
// numUpdatedRows must be 1n so the #64 phantom-broadcast gate passes and
// movePage proceeds to emit PAGE_MOVED instead of early-returning.
updatePage: jest.fn().mockResolvedValue({ numUpdatedRows: 1n }),
};
const eventEmitter = { emit: jest.fn() };
const svc = new PageService(
pageRepo as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
{} as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
{} as any, // generalQueue
eventEmitter as any, // eventEmitter
{} as any, // collaborationGateway
{} as any, // watcherService
{} as any, // transclusionService
);
// getPageBreadCrumbs is a method on PageService itself (it runs a recursive
// ancestor CTE against the db). Spy on the instance method so we can return
// a synthetic ancestor chain without a real database.
jest
.spyOn(svc, 'getPageBreadCrumbs')
.mockResolvedValue((overrides?.breadcrumbs ?? []) as any);
return { svc, pageRepo, eventEmitter };
};
// movePage takes `movedPage` as a param. Keep its parentPageId distinct from
// the dto's parentPageId so the re-parent branch (and thus the cycle guard)
// actually runs instead of short-circuiting to a same-parent reorder.
const makeMovedPage = (): Page =>
({
id: 'page-1',
parentPageId: 'old-parent',
spaceId: SPACE_ID,
workspaceId: 'ws-1',
slugId: 'slug-1',
title: 'Page 1',
icon: null,
}) as any;
it('rejects a self-move (parentPageId === pageId) without updating', async () => {
const { svc, pageRepo } = makeService();
const dto: MovePageDto = {
pageId: 'page-1',
position: VALID_POSITION,
parentPageId: 'page-1', // moving the page into itself
};
await expect(svc.movePage(dto, makeMovedPage())).rejects.toThrow(
BadRequestException,
);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('rejects moving a page into its own subtree (cycle) before updating', async () => {
// Destination's ancestor chain includes the page being moved -> the
// destination lives inside the moved page's subtree -> cycle.
const { svc, pageRepo } = makeService({
breadcrumbs: [
{ id: 'dest-parent' },
{ id: 'page-1' }, // the moved page appears among the destination's ancestors
{ id: 'root' },
],
});
const dto: MovePageDto = {
pageId: 'page-1',
position: VALID_POSITION,
parentPageId: 'dest-parent',
};
await expect(svc.movePage(dto, makeMovedPage())).rejects.toThrow(
BadRequestException,
);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('allows a legitimate move when the destination is not in the subtree', async () => {
// Destination's ancestor chain does NOT contain the moved page -> no cycle.
const { svc, pageRepo } = makeService({
breadcrumbs: [{ id: 'dest-parent' }, { id: 'root' }],
});
const dto: MovePageDto = {
pageId: 'page-1',
position: VALID_POSITION,
parentPageId: 'dest-parent',
};
await expect(svc.movePage(dto, makeMovedPage())).resolves.not.toThrow();
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,60 @@
import { injectTrackerHead } from './inject-tracker-head.util';
// Pins the public-share trackerHead injection invariant (ShareSeoController).
// The admin snippet is trusted content and MUST land byte-for-byte before the
// first </head>. The critical regression these tests guard is the function vs
// string replacer: a string replacement interprets `$&`/`$$`/`` $` ``/`$'`
// inside the snippet as substitution patterns and mangles the tracker. The
// byte-for-byte test below FAILS on the old string-replacer implementation and
// passes only with the function replacer.
const HTML = '<html><head><title>t</title></head><body>b</body></html>';
describe('injectTrackerHead', () => {
it('inserts the snippet immediately before the first </head>', () => {
const out = injectTrackerHead(HTML, '<script>ga()</script>');
expect(out).toBe(
'<html><head><title>t</title><script>ga()</script>\n</head><body>b</body></html>',
);
});
it('inserts a snippet containing $& byte-for-byte (function replacer)', () => {
const snippet = '<script>var a="$&";</script>';
const out = injectTrackerHead(HTML, snippet);
expect(out).toContain(`${snippet}\n</head>`);
// The literal "$&" survives; a string replacer would have spliced in the
// matched "</head>" here.
expect(out).toContain('$&');
expect(out).not.toContain('</head>"');
});
it('inserts a snippet containing $$, $` and $\' byte-for-byte', () => {
// All four special replacement patterns in one snippet.
const snippet = "<!-- $$ $` $' $& -->";
const out = injectTrackerHead(HTML, snippet);
expect(out).toContain(`${snippet}\n</head>`);
});
it('returns html unchanged for an empty trackerHead', () => {
expect(injectTrackerHead(HTML, '')).toBe(HTML);
});
it('returns html unchanged for a whitespace-only trackerHead', () => {
expect(injectTrackerHead(HTML, ' \n\t ')).toBe(HTML);
});
it('returns html unchanged for an undefined trackerHead', () => {
expect(injectTrackerHead(HTML, undefined)).toBe(HTML);
});
it('returns html unchanged when there is no </head> marker', () => {
const noHead = '<html><body>no head here</body></html>';
expect(injectTrackerHead(noHead, '<script>ga()</script>')).toBe(noHead);
});
it('injects before only the FIRST </head> when several exist', () => {
const twoHeads = '<head></head><head></head>';
const out = injectTrackerHead(twoHeads, 'X');
expect(out).toBe('<head>X\n</head><head></head>');
});
});

View File

@@ -0,0 +1,30 @@
/**
* Injects an admin-authored analytics/tracker snippet verbatim into the
* <head> of a public-share page.
*
* `trackerHead` is admin-only trusted content (writable only via the
* admin-gated workspace settings) and must be inserted BYTE-FOR-BYTE before the
* first `</head>` marker. A plain string replacement would interpret `$&`,
* `$$`, `` $` `` and `$'` inside the snippet as substitution patterns and mangle
* the tracker, so a FUNCTION replacer is used: its return value is inserted
* literally with no special-pattern interpretation.
*
* The snippet is deliberately NOT escaped (it is trusted HTML/JS). Returns the
* html unchanged when:
* - trackerHead is undefined / empty / whitespace-only, or
* - there is no `</head>` marker to anchor the injection.
*/
export function injectTrackerHead(
html: string,
trackerHead: string | undefined,
): string {
if (typeof trackerHead !== 'string' || trackerHead.trim().length === 0) {
return html;
}
if (!html.includes('</head>')) {
return html;
}
// Function replacer: the return value is inserted literally, so `$&`/`$$`/
// `` $` ``/`$'` in the admin snippet are NOT treated as substitution patterns.
return html.replace('</head>', () => `${trackerHead}\n</head>`);
}

View File

@@ -8,6 +8,7 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { htmlEscape } from '../../common/helpers/html-escaper';
import { injectTrackerHead } from './inject-tracker-head.util';
@Controller('share')
export class ShareSeoController {
@@ -97,22 +98,20 @@ export class ShareSeoController {
// pages only. It is trusted content, so it is NOT escaped. The htmlEmbed
// block itself is sandboxed and is the safe surface for everyone else.
const trackerHead = (workspace?.settings as any)?.trackerHead;
if (typeof trackerHead === 'string' && trackerHead.trim().length > 0) {
if (transformedHtml.includes('</head>')) {
// Function replacer: the snippet is admin-authored trusted content and
// must be injected verbatim. A string replacement would interpret `$&`,
// `$'`, `` $` `` and `$$` inside it as substitution patterns and mangle
// the tracker; a function return value is inserted literally.
transformedHtml = transformedHtml.replace(
'</head>',
() => `${trackerHead}\n</head>`,
);
} else {
const beforeInjection = transformedHtml;
transformedHtml = injectTrackerHead(transformedHtml, trackerHead);
if (
beforeInjection === transformedHtml &&
typeof trackerHead === 'string' &&
trackerHead.trim().length > 0
) {
// A non-empty snippet was configured but nothing was injected: the only
// reason injectTrackerHead leaves the html unchanged for a non-empty
// snippet is a missing </head> marker.
this.logger.warn(
'trackerHead is configured but no </head> marker was found in the share index HTML; tracker snippet was not injected.',
);
}
}
res.type('text/html').send(transformedHtml);
}

View File

@@ -0,0 +1,95 @@
import { ForbiddenException } from '@nestjs/common';
import { WorkspaceController } from './workspace.controller';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import { UserRole } from '../../../common/helpers/types/permission';
// Pins the admin gate on WorkspaceController.updateWorkspace: writing workspace
// settings (including the admin-only trackerHead snippet and the htmlEmbed
// toggle) requires Manage settings ability. A MEMBER must be Forbidden BEFORE
// workspaceService.update is ever called; OWNER/ADMIN pass through.
//
// The REAL WorkspaceAbilityFactory is used (the gate under test); only the leaf
// service deps are stubbed. The controller is constructed directly with stubs,
// mirroring the other controller specs in this codebase.
function buildController() {
const update = jest
.fn()
.mockResolvedValue({ id: 'w1', hostname: 'acme' });
const workspaceService = { update };
const controller = new WorkspaceController(
workspaceService as any,
{} as any, // workspaceInvitationService
new WorkspaceAbilityFactory(), // REAL ability factory (the gate under test)
{} as any, // workspaceRepo
{} as any, // environmentService
{} as any, // licenseCheckService
);
return { controller, update };
}
const res = { clearCookie: jest.fn() } as any;
const workspace = { id: 'w1', hostname: 'acme' } as any;
const userWith = (role: UserRole) => ({ id: 'u1', role }) as any;
describe('WorkspaceController.updateWorkspace settings gate', () => {
it('forbids a MEMBER from writing trackerHead and never calls update', async () => {
const { controller, update } = buildController();
await expect(
controller.updateWorkspace(
res,
{ trackerHead: '<script>ga()</script>' } as any,
userWith(UserRole.MEMBER),
workspace,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(update).not.toHaveBeenCalled();
});
it('forbids a MEMBER from toggling htmlEmbed and never calls update', async () => {
const { controller, update } = buildController();
await expect(
controller.updateWorkspace(
res,
{ htmlEmbed: true } as any,
userWith(UserRole.MEMBER),
workspace,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(update).not.toHaveBeenCalled();
});
it('allows an OWNER to write trackerHead (update is called with the dto)', async () => {
const { controller, update } = buildController();
const dto = { trackerHead: '<script>ga()</script>' } as any;
await controller.updateWorkspace(
res,
dto,
userWith(UserRole.OWNER),
workspace,
);
expect(update).toHaveBeenCalledWith('w1', dto);
});
it('allows an ADMIN to write trackerHead (update is called with the dto)', async () => {
const { controller, update } = buildController();
const dto = { trackerHead: '<script>ga()</script>' } as any;
await controller.updateWorkspace(
res,
dto,
userWith(UserRole.ADMIN),
workspace,
);
expect(update).toHaveBeenCalledWith('w1', dto);
});
});

View File

@@ -0,0 +1,66 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateWorkspaceDto } from './update-workspace.dto';
// API-boundary validation for the two html-embed/tracker settings fields:
// - trackerHead: optional string, max 20000 chars (admin-authored snippet);
// - htmlEmbed: optional boolean (workspace master toggle).
// All other fields are optional, so a payload carrying just the field under test
// isolates that field's constraints.
async function validateDto(payload: Record<string, unknown>) {
const dto = plainToInstance(UpdateWorkspaceDto, payload);
return validate(dto as object);
}
function hasError(errors: any[], property: string, constraint?: string) {
const err = errors.find((e) => e.property === property);
if (!err) return false;
if (!constraint) return true;
return Object.keys(err.constraints ?? {}).includes(constraint);
}
describe('UpdateWorkspaceDto.trackerHead validation', () => {
it('accepts a normal trackerHead string', async () => {
const errors = await validateDto({ trackerHead: '<script>ga()</script>' });
expect(hasError(errors, 'trackerHead')).toBe(false);
});
it('accepts exactly 20000 characters', async () => {
const errors = await validateDto({ trackerHead: 'a'.repeat(20000) });
expect(hasError(errors, 'trackerHead')).toBe(false);
});
it('rejects 20001 characters with a maxLength error', async () => {
const errors = await validateDto({ trackerHead: 'a'.repeat(20001) });
expect(hasError(errors, 'trackerHead', 'maxLength')).toBe(true);
});
it('rejects a non-string trackerHead with an isString error', async () => {
const errors = await validateDto({ trackerHead: 123 });
expect(hasError(errors, 'trackerHead', 'isString')).toBe(true);
});
it('accepts an omitted trackerHead (optional)', async () => {
const errors = await validateDto({});
expect(hasError(errors, 'trackerHead')).toBe(false);
});
});
describe('UpdateWorkspaceDto.htmlEmbed validation', () => {
it('accepts htmlEmbed: true', async () => {
const errors = await validateDto({ htmlEmbed: true });
expect(hasError(errors, 'htmlEmbed')).toBe(false);
});
it('accepts htmlEmbed: false', async () => {
const errors = await validateDto({ htmlEmbed: false });
expect(hasError(errors, 'htmlEmbed')).toBe(false);
});
it('rejects a non-boolean htmlEmbed with an isBoolean error', async () => {
const errors = await validateDto({ htmlEmbed: 'yes' });
expect(hasError(errors, 'htmlEmbed', 'isBoolean')).toBe(true);
});
});

View File

@@ -142,4 +142,62 @@ describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)',
expect(logged.changes.before.trackerHead).toBe('');
expect(logged.changes.after.trackerHead).toBe('<script>m()</script>');
});
it('still persists trackerHead on a no-op re-save (prev === input)', async () => {
// updateSetting must run even when the value is unchanged: the toggle write
// is idempotent and should not be skipped just because the audit diff is
// empty.
const { service, updateSetting } = buildService({
settingsBefore: { trackerHead: '<script>same()</script>' },
});
await service.update('w1', {
trackerHead: '<script>same()</script>',
} as any);
expect(updateSetting).toHaveBeenCalledWith(
'w1',
'trackerHead',
'<script>same()</script>',
expect.anything(),
);
});
it('does NOT audit a no-op trackerHead re-save (no before/after diff)', async () => {
// prev === input, and trackerHead is the only field touched, so the audit
// diff is empty and auditService.log must NOT fire — trackerHead never
// enters the audit payload on a no-op.
const { service, auditService } = buildService({
settingsBefore: { trackerHead: '<script>same()</script>' },
});
await service.update('w1', {
trackerHead: '<script>same()</script>',
} as any);
expect(auditService.log).not.toHaveBeenCalled();
});
it('keeps trackerHead OUT of the audit diff on a no-op while another field changes', async () => {
// trackerHead is re-saved identically (no-op) but htmlEmbed flips, so an
// audit IS logged — yet it must carry only htmlEmbed, never the unchanged
// trackerHead key.
const { service, auditService } = buildService({
settingsBefore: {
trackerHead: '<script>same()</script>',
htmlEmbed: false,
},
});
await service.update('w1', {
trackerHead: '<script>same()</script>',
htmlEmbed: true,
} as any);
expect(auditService.log).toHaveBeenCalledTimes(1);
const logged = auditService.log.mock.calls[0][0];
expect(logged.changes.after.htmlEmbed).toBe(true);
expect('trackerHead' in logged.changes.before).toBe(false);
expect('trackerHead' in logged.changes.after).toBe(false);
});
});

View File

@@ -0,0 +1,50 @@
import { resolveTrustProxy } from './trust-proxy.util';
/**
* Unit tests for resolveTrustProxy: the helper that turns the TRUST_PROXY env
* string into a Fastify trustProxy value. The contract is: empty/undefined
* falls back to the safe loopback/linklocal/uniquelocal default (so a public-IP
* client cannot spoof X-Forwarded-For); 'true'/'false' become booleans; a
* non-negative integer becomes a hop count (number); anything else (CIDR/IP
* lists, negative numbers, named keywords) is passed through verbatim as a
* trimmed string.
*/
describe('resolveTrustProxy', () => {
const SAFE_DEFAULT = 'loopback, linklocal, uniquelocal';
it('returns the safe default for an empty string', () => {
expect(resolveTrustProxy('')).toBe(SAFE_DEFAULT);
});
it('returns the safe default for undefined', () => {
expect(resolveTrustProxy(undefined)).toBe(SAFE_DEFAULT);
});
it("returns the boolean true for 'true'", () => {
expect(resolveTrustProxy('true')).toBe(true);
});
it("returns the boolean false for 'false'", () => {
expect(resolveTrustProxy('false')).toBe(false);
});
it("returns the number 2 for '2'", () => {
expect(resolveTrustProxy('2')).toBe(2);
});
it("trims surrounding whitespace and returns the number 3 for ' 3 '", () => {
expect(resolveTrustProxy(' 3 ')).toBe(3);
});
it('passes a CIDR string through unchanged', () => {
expect(resolveTrustProxy('10.0.0.0/8')).toBe('10.0.0.0/8');
});
it("passes a negative number through as a string ('-1' is not a valid hop count)", () => {
expect(resolveTrustProxy('-1')).toBe('-1');
});
it('passes a non-numeric keyword through unchanged', () => {
expect(resolveTrustProxy('loopback')).toBe('loopback');
});
});

View File

@@ -0,0 +1,14 @@
// Trust X-Forwarded-For ONLY from real proxies on private/loopback nets by
// default, so a public-IP client cannot spoof its IP via X-Forwarded-For.
// TRUST_PROXY env overrides: 'true'/'false', a hop count (integer), or a
// CIDR/IP list string passed through to Fastify/proxy-addr.
export function resolveTrustProxy(
rawInput?: string,
): boolean | number | string {
const raw = rawInput?.trim();
if (raw == null || raw === '') return 'loopback, linklocal, uniquelocal';
if (raw === 'true') return true;
if (raw === 'false') return false;
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : raw;
}

View File

@@ -14,19 +14,7 @@ import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers';
// Trust X-Forwarded-For ONLY from real proxies on private/loopback nets by
// default, so a public-IP client cannot spoof its IP via X-Forwarded-For.
// TRUST_PROXY env overrides: 'true'/'false', a hop count (integer), or a
// CIDR/IP list string passed through to Fastify/proxy-addr.
function resolveTrustProxy(rawInput?: string): boolean | number | string {
const raw = rawInput?.trim();
if (raw == null || raw === '') return 'loopback, linklocal, uniquelocal';
if (raw === 'true') return true;
if (raw === 'false') return false;
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : raw;
}
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(

View File

@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from "vitest";
import {
encodeHtmlEmbedSource,
decodeHtmlEmbedSource,
parseHtmlEmbedHeight,
renderHtmlEmbedHeight,
} from "./html-embed";
// Unit coverage for the base64 codec used by the htmlEmbed node's
@@ -118,6 +120,45 @@ describe("html-embed codec — encode failure fallback", () => {
});
});
describe("html-embed height — parseHtmlEmbedHeight (data-height -> px | null)", () => {
it('parses a numeric string ("300" -> 300)', () => {
expect(parseHtmlEmbedHeight("300")).toBe(300);
});
it("parses an absent value (null -> null = auto-resize)", () => {
expect(parseHtmlEmbedHeight(null)).toBeNull();
expect(parseHtmlEmbedHeight("")).toBeNull();
});
it('rejects a non-numeric value ("abc" -> null) — pins the NaN guard (BUG-2)', () => {
// Without Number.isFinite this would be NaN (typeof "number"), disabling
// auto-resize and yielding an unclamped iframe height downstream.
expect(parseHtmlEmbedHeight("abc")).toBeNull();
});
it('parses a trailing-unit value ("120px" -> 120) via parseInt', () => {
expect(parseHtmlEmbedHeight("120px")).toBe(120);
});
});
describe("html-embed height — renderHtmlEmbedHeight (px -> data-height | {})", () => {
it("renders a fixed height (120 -> { data-height: '120' })", () => {
expect(renderHtmlEmbedHeight(120)).toEqual({ "data-height": "120" });
});
it("renders auto-resize as no attribute (null -> {})", () => {
expect(renderHtmlEmbedHeight(null)).toEqual({});
});
it("renders 0 as no attribute (0 is auto -> {})", () => {
expect(renderHtmlEmbedHeight(0)).toEqual({});
});
it("renders undefined as no attribute (absent -> {})", () => {
expect(renderHtmlEmbedHeight(undefined)).toEqual({});
});
});
describe("html-embed codec — decode of malformed input (browser branch)", () => {
it("returns '' for input atob rejects (catch branch)", () => {
// atob throws on characters outside the base64 alphabet; the codec catches

View File

@@ -69,6 +69,30 @@ export function decodeHtmlEmbedSource(encoded: string): string {
}
}
/**
* Parse the `data-height` attribute value into a fixed iframe height in px.
*
* Returns null (auto-resize) when the value is absent, empty, or non-numeric.
* A non-numeric `data-height` (e.g. a crafted/corrupted import) must NOT become
* NaN: NaN is typeof "number" and would disable auto-resize and yield an
* unclamped iframe height downstream. The Number.isFinite guard pins that fix.
*/
export function parseHtmlEmbedHeight(value: string | null): number | null {
if (!value) return null;
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : null;
}
/**
* Render a fixed height back to a `data-height` attribute. A null/0/absent
* height means auto-resize, so no attribute is emitted.
*/
export function renderHtmlEmbedHeight(
height: number | null | undefined,
): { "data-height": string } | Record<string, never> {
return height ? { "data-height": String(height) } : {};
}
export const HtmlEmbed = Node.create<HtmlEmbedOptions>({
name: "htmlEmbed",
inline: false,
@@ -103,17 +127,9 @@ export const HtmlEmbed = Node.create<HtmlEmbedOptions>({
// Fixed iframe height in px. null/absent => auto-resize on the client.
height: {
default: null,
parseHTML: (el) => {
const v = el.getAttribute("data-height");
if (!v) return null;
const n = parseInt(v, 10);
// A non-numeric data-height (e.g. crafted/corrupted import) must not
// become NaN: NaN is typeof "number" and would disable auto-resize and
// yield an unclamped iframe height downstream. Treat it as auto (null).
return Number.isFinite(n) ? n : null;
},
parseHTML: (el) => parseHtmlEmbedHeight(el.getAttribute("data-height")),
renderHTML: (attrs: HtmlEmbedAttributes) =>
attrs.height ? { "data-height": String(attrs.height) } : {},
renderHtmlEmbedHeight(attrs.height),
},
};
},

View File

@@ -53,6 +53,10 @@ const cases = {
video: docOf({ type: "video", attrs: { src: "http://x/v.mp4" } }),
youtube: docOf({ type: "youtube", attrs: { src: "http://y/watch" } }),
embed: docOf({ type: "embed", attrs: { src: "http://e", provider: "iframe" } }),
htmlEmbed: docOf({
type: "htmlEmbed",
attrs: { source: "<script>track()</script>", height: 320 },
}),
drawio: docOf({ type: "drawio", attrs: { src: "http://d" } }),
excalidraw: docOf({ type: "excalidraw", attrs: { src: "http://e" } }),
columns: docOf({
@@ -75,3 +79,19 @@ for (const [name, doc] of Object.entries(cases)) {
});
});
}
// htmlEmbed is the sandboxed raw-HTML block. The MCP write path carries it
// through Yjs (toYdoc -> fromYdoc) without rendering, so a full round-trip must
// preserve both the `source` snippet and the numeric `height`.
test("htmlEmbed round-trips source and height through Yjs", () => {
const doc = docOf({
type: "htmlEmbed",
attrs: { source: "<iframe src='x'></iframe>", height: 480 },
});
const ydoc = TiptapTransformer.toYdoc(doc, "default", docmostExtensions);
const back = TiptapTransformer.fromYdoc(ydoc, "default");
const node = back.content.find((n) => n.type === "htmlEmbed");
assert.ok(node, "htmlEmbed node survives the round-trip");
assert.equal(node.attrs.source, "<iframe src='x'></iframe>");
assert.equal(node.attrs.height, 480);
});